Godot Attack Hitbox Basics: Stop One Attack from Damaging the Same Target Over and Over

Created: 2026-09-22Last updated: 2026-10-10

Build an Area2D attack hitbox that deals damage only once per target per attack, by remembering who each swing has hit.

Attacks like a sword swing or a sweeping slash should, as a rule, deal damage only once per attack to each target inside the range. In Godot, you build this with an Area2D hitbox and a list that remembers who this attack has hit.

Reduce HP straight from the signal that arrives when something enters the range, and one attack can drop the same slime's HP three times. On the flip side, a second swing in the same spot may not land at all.

This article covers counting "the targets this attack has hit" instead of the number of signals, so that damage lands only once per target.

Two slimes inside the sword's range each take damage only once from a single attack, and both Entered and Applied read 2

What You'll Learn

  • What area_entered actually counts, and the three ways damage can stack on the same target
  • Remembering who you've hit, using "one attack × one target" as the unit
  • Why the second attack stops landing, and how to pick up targets that are already overlapping

Tested with: Godot 4.7

Sponsored

What area_entered counts

Area2D's area_entered comes exactly once, when two areas start to overlap. It doesn't arrive every frame while they overlap; it's only the single point where the overlap begins.

To check whether something is overlapping right now, use get_overlapping_areas() or overlaps_area(). These don't report the start; they answer with the overlaps known as of the most recent physics update (60 times per second by default). Building separately for "the moment of entry" and "while inside" is covered in the Area2D article.

area_entered comes once at the single point where the overlap begins, while get_overlapping_areas() keeps answering for as long as the overlap lasts

If the signal comes once and you deal damage once there, the damage should land only once. But in practice, it drops three times over.

What's off is where the counting happens. area_entered doesn't count attacks or targets; it counts how many times an overlap was newly detected. "At most once per target per attack" doesn't exist on the signal's side. The attacking side has to keep that count itself.

In other engines: For overlap detection, Godot's Area2D plays a role close to Unity's Collider2D with "Is Trigger" enabled, or a collision component with Overlap responses set up in Unreal Engine. Having to track "once per target per attack" yourself is the same there too.

Three ways damage stacks

A sword in a Metroidvania, punches in a beat 'em up, a sweeping slash in a top-down ARPG—all of them get stuck in one of the same three places.

The three ways damage stacks on the same target: leaving and re-entering, two receivers, and reducing HP every frame

The first is that the overlap breaks and re-enters. When the sword's hitbox leaves the body and comes back, it's detected as a new overlap every time. This really happens with arcing swings. It also happens if you turn the hitbox on only during the animation frames where the attack can land (active frames). While it's off, the overlap counts as ended, and when you turn it back on and the overlap is found again, it's counted as a re-entry.

The second is that the same contact is received in two places. The attacking side's Area2D and the target's HurtBox (the Area2D on the receiving side, also called a hurt area) both have area_entered. If both reduce HP separately, one contact deals damage twice. The same goes for an enemy whose collision is split into a head and a torso as separate Area2Ds. area_shape_entered arrives once per pair of shapes (the CollisionShape2D children of an Area2D). Even with a single Area2D, two shapes means two arrivals.

The third is turning the overlap state into damage every frame. This is the setup where you loop over the list of overlaps inside _physics_process() and reduce HP right there. It isn't about signals at all; you're simply dealing damage on every physics update. At the default 60 per second, half a second of overlap lands about 30 hits.

All three stop once you funnel HP reduction through a single exit and count the targets hit there.

Sponsored

The list of targets hit

Making one exit is more reliable than blocking every entrance. Right before dealing damage, check "has this attack already hit this target?", and if not, record it and then reduce HP.

var is_attacking := false
var hit_targets: Array[Node] = []

func _try_hit(area: Area2D) -> void:
	if not is_attacking:
		return                          # ignore contact while not attacking
	var box := area as HurtBox          # null if it isn't a HurtBox
	if box == null or box.target == null:
		return
	if hit_targets.has(box.target):     # this attack already hit it
		return
	hit_targets.append(box.target)      # record first, then reduce
	box.target.take_damage(10)
However many entrances there are, only the first pass gets through the list of targets hit, and HP is reached only once

It doesn't matter how many entrances there are. As long as they all go through the same list, only the first one gets past it. The condition is that the line that reduces HP exists only inside this function. If the HurtBox side also reduces HP on its own, that part slips through without passing the list.

Record the body that holds the HP, not the HurtBox. For an enemy whose collision is split into a head and a torso, a setup that records HurtBoxes lets both through as separate targets.

So how do you get from the HurtBox to the body? area.get_parent() is right only when the HurtBox is a direct child of the body. area.owner describes ownership for saving the scene, and there's no guarantee it points to the node holding the HP. Let the HurtBox carry its own destination.

class_name HurtBox
extends Area2D

@export var target: Node   # the body that holds the HP. Set it in the Inspector

class_name HurtBox on the first line gives this script a type name. The area as HurtBox in _try_hit() treats the received Area2D as this type. For an Area2D without this script, it becomes null, so the box == null line filters it out.

Giving the HurtBox a destination makes both the head and torso point to the same body, while get_parent() stops at the Body node in between

Empty the list right before the attack starts. If you forget targets in area_exited, a second hit goes through when the sword leaves the body and re-enters within the same swing.

func _start_attack() -> void:
	is_attacking = true
	hit_targets.clear()     # this is the boundary of "one attack"
Forgetting in area_exited lets a second hit through on re-entry, while emptying at attack start lands exactly once per swing

Picking up targets that are already overlapping

At this point, damage no longer stacks on the same target. But swing a second time in the same spot, and this time it doesn't land at all.

area_entered only comes when an overlap starts. A target that has stayed inside the range since the first attack gets no new signal on the second. Emptying the list does nothing if nothing flows into it.

A target that stays overlapped gets no second area_entered, so check who's overlapping right now when the attack starts and pick them up

The signal tells you about targets that came in partway through. For the ones already inside, you get the list with get_overlapping_areas() the moment the attack starts and check them one by one.

func _start_attack() -> void:
	is_attacking = true
	hit_targets.clear()
	for area in sword_arc.get_overlapping_areas():
		_try_hit(area)      # also check targets already inside the range

Now there are two entrances: the signal and the check at the start. Even so, both go through the same _try_hit() and check the same list, so damage still lands only once.

Sponsored

Hands-On: Cut Two Slimes at Once With One Attack

You'll build an attack that catches several enemies in one swing, like a spin attack in a top-down ARPG or a sweeping slash in a beat 'em up. Line up two idle slimes in a new scene, attack from between them, and it's a success if both drop from 100 to 90 exactly once.

Show two numbers side by side on screen: the number of times area_entered came (Entered), and the number of times damage was actually applied (Applied).

The two slimes' HurtBoxes (radius 24), 100 px to the left and right, overlap the Player's sword circle (radius 120)

① What to place

Under Main (Node2D), place these four.

NodePositionChildren
Player (Node2D)(320, 300)Child SwordArc (Area2D); its child CollisionShape2D is a circle with radius 120
Slime1 (Node2D)(220, 300)Children HurtBox (Area2D) and HpLabel (Label, position (0, -40)). The HurtBox's child CollisionShape2D is a circle with radius 24
Slime2 (Node2D)(420, 300)Same setup as Slime1
HUD (CanvasLayer)—Two Label children: EnterLabel at (16, 16) and HitLabel at (160, 16)

CanvasLayer is a node that keeps what it shows at the same place on screen even when the camera moves. Right-click SwordArc, EnterLabel, and HitLabel and turn on "Access as Unique Name." A % appears next to the name, and you can grab them directly. There are no sprite nodes. Before running, turn on Visible Collision Shapes from the Debug menu at the top of the editor to see the sword's and slimes' circles.

② Put it together

The script on HurtBox only holds one destination.

# hurt_box.gd
class_name HurtBox
extends Area2D

@export var target: Node   # the body that holds the HP. Set the slime body in the Inspector

In the Inspector, drag each HurtBox's own body (Slime1 or Slime2) into its Target. Attach the same slime.gd to both bodies.

# slime.gd
extends Node2D

@export var max_hp := 100

var hp := 0

@onready var hp_label: Label = $HpLabel

func _ready() -> void:
	hp = max_hp
	_show_hp()

func take_damage(amount: int) -> void:
	hp = maxi(hp - amount, 0)
	_show_hp()

func _show_hp() -> void:
	hp_label.text = str(hp)

The main part goes on Player.

# player.gd
extends Node2D

const SPEED := 200.0
const DAMAGE := 10
const SWING_TIME := 0.3

var is_attacking := false
var hit_targets: Array[Node] = []
var enter_count := 0
var hit_count := 0

@onready var sword_arc: Area2D = %SwordArc
@onready var enter_label: Label = %EnterLabel
@onready var hit_label: Label = %HitLabel

func _ready() -> void:
	sword_arc.area_entered.connect(_on_arc_area_entered)   # connect only here, once
	_update_hud()

func _process(delta: float) -> void:
	position.x += Input.get_axis("ui_left", "ui_right") * SPEED * delta
	if Input.is_action_just_pressed("ui_accept"):
		_start_attack()

func _start_attack() -> void:
	if is_attacking:
		return
	is_attacking = true
	hit_targets.clear()                          # empty this attack's record
	for area in sword_arc.get_overlapping_areas():
		_try_hit(area)                           # also check targets already inside the range
	await get_tree().create_timer(SWING_TIME).timeout   # wait 0.3 seconds for the swing to finish
	is_attacking = false

func _on_arc_area_entered(area: Area2D) -> void:
	enter_count += 1                             # count whether attacking or not
	_update_hud()
	_try_hit(area)                               # a target that came in mid-swing

func _try_hit(area: Area2D) -> void:
	if not is_attacking:
		return
	var box := area as HurtBox
	if box == null or box.target == null:
		return
	if hit_targets.has(box.target):
		return
	hit_targets.append(box.target)
	hit_count += 1
	box.target.take_damage(DAMAGE)
	_update_hud()

func _update_hud() -> void:
	enter_label.text = "Entered: %d" % enter_count
	hit_label.text = "Applied: %d" % hit_count

③ Run it and check

Right after starting, Entered becomes 2, and each press raises only Applied by 2 while Entered stays at 2

After running, first look at the screen without pressing anything. "Entered" becomes 2. area_entered arrives as soon as the ranges overlap, without waiting for an attack. "Applied" is 0 because _try_hit() filters it out at the top while is_attacking is false.

Once the display reads "Entered: 2 / Applied: 0," press the space key once. Both slimes' HP becomes 90, and "Applied" goes up to 2. What moved was the check at the start; no signal came, so "Entered" stays at 2. If "Applied" stays at 0 after pressing, either the HurtBox's Target is empty or hurt_box.gd isn't attached. Both silently return partway through _try_hit(), so no error appears.

Wait about half a second and press again. Input during an attack is discarded at the top of _start_attack(). Pressing sooner than 0.3 seconds (SWING_TIME) does nothing.

HP becomes 80, and "Applied" becomes 4. "Entered" never moves from the initial 2. All four hits came in through the check at the start. If you had relied on the signal alone, with this placement no number of presses would have landed.

Finally, hold down the right arrow key. Once Player's X passes 564, the sword's circle leaves both slimes' circles. Come back to the original position with the left arrow key, and "Entered" goes up to 4. Re-entries are counted on the signal's side. You aren't attacking, so "Applied" stays at 4.

Two points.

  • Narrow it at the exit, rather than reducing the entrances: both area_entered and the check at the start flow into the same _try_hit(). Even with two entrances, damage doesn't double, because it checks hit_targets before recording
  • Record the body that holds the HP: going through the HurtBox's target, an enemy counts as one even when its collision is split into a head and a torso. Adding more hitboxes doesn't add more places to rewrite

Brief invincibility on the receiving side, and settings that keep allies' attacks from hitting allies, are covered in the Collision Layer and Mask article.

Troubleshooting

SymptomCauseFix
HP drops just from an enemy coming close, without pressing the attack buttonarea_entered comes without waiting for an attack, and the receiving function reduces HP directlyCheck is_attacking at the top of _try_hit(). Put the same line in when you add entrances, as with beams
Overlapping the sword with your own game's enemy (CharacterBody2D) does nothingarea_entered and get_overlapping_areas() only pick up Area2Ds. Bodies don't countPut an Area2D for receiving hits (HurtBox) under the enemy. To pick up bodies directly, use body_entered and get_overlapping_bodies()
After adding a HurtBox for receiving hits to the player, your own HP drops every time you attackThe sword's circle is centered on you and overlaps your own HurtBox too. With Layer and Mask both at the default 1, the check at the start includes youSeparate the Layers of your HurtBox and the enemies' HurtBoxes, and put only the enemies' number in SwordArc's Mask. How to split them is in the Collision Layer and Mask article
Both "Entered" and "Applied" stay at 0SwordArc's Collision Mask doesn't include the HurtBox's Collision Layer. This happens when you change either Layer from the default 1In the Inspector, under SwordArc's Collision → Mask, turn on the same number as the HurtBox's Layer

Bonus: Good to Know First

  • Attacks that keep hitting while held: for something that hits at a fixed interval, like a beam or a flamethrower, keep each target's "next time it can be hit" in a dictionary. A target that stays overlapped never gets a signal, so use a Timer to look at get_overlapping_areas() every 0.1 seconds. Dealing damage while inside a range is covered in the poison pool example
  • Connect once, in _ready(): connecting the same function with connect() on every attack is an error the second time and doesn't add another. Connecting a new unnamed function written on the spot (a lambda) each time does add connections, but as long as they go through _try_hit(), the second one is stopped by the list
  • Turn hitboxes on and off after physics processing: changing monitoring or CollisionShape2D's disabled directly from inside area_entered is an error. Defer it, as in set_deferred("monitoring", false). Right after turning one on, get_overlapping_areas() may still be empty

Summary

  • What's counted is overlaps: area_entered is the number of times an overlap was newly detected. Not the number of attacks, and not the number of targets hit
  • There are three ways damage stacks: leaving and re-entering, receiving the same contact in two places, and turning overlap into damage every frame
  • Keep the HP-reducing line in one function: check "has this attack already hit this target?" before recording
  • Record the body, and empty the list right before the attack: record the body that holds the HP. Empty the list right before the attack starts
  • Check overlaps at the start: a target that stays overlapped gets no signal, so check it with get_overlapping_areas(). Even with two entrances, damage lands once

The impact at the moment of a hit is in the hitstop article, and knocking things back after a hit is in the knockback article. How many enemies should the attack you're building now be allowed to hit in one swing?

Read nextNext in the learning path
Physics

Godot RayCast2D Basics: An Enemy That Turns Back Before Ledges and Can't See Through Walls

Read this note
Godot Notes in this section104