doc 3 of 5

Part 02 — The anatomy of an attack

You will build: one attack whose windup, active hitbox, and recovery all come out of a single animation clip — the hitbox opening and closing on keyframes instead of on a Timer, landing exactly one hit per swing, with hit-stop and a flash on contact.

Part 01 gave the boss a shape: idle, telegraph, strike, stand open. The timing inside that strike was a stack of Timer nodes, and it worked well enough to see the loop. This part takes that timing apart, watches it fail in three different ways in the same afternoon, and rebuilds it around one clock instead of two.

Three parts, and only one of them is the attack#

Every melee attack in every game you have played is three windows in a row.

Windup. The boss commits. The hitbox is off. This is the part the player reacts to.

Active. The hitbox exists. This is the part that deals damage, and it is startlingly short.

Recovery. The boss is finished and cannot cancel. This is the part the player is paid with.

Budget them in frames, not in seconds and not in feel. Frames are the unit players actually perceive, they survive a change of animation length, and they are the unit every fighting-game frame-data table on the internet is written in. At 60 physics ticks per second, a reasonable first swing looks like this:

PhaseFramesSecondsWhat the player is doing
Windup240.400reading the pose, deciding, moving
Active40.067already committed, one way or the other
Recovery300.500punishing, or getting back to neutral

58 frames total. Slightly under a second, and only 4 of those frames can hurt anybody.

A readable swing — 24 / 4 / 30. 58 frames at 60fps, 0.97 seconds total: windup 24 frames (0.40s), active 4 frames (0.07s), recovery 30 frames (0.50s). The timeline marker runs at 3× slower than real time.The table gives you the numbers; the bar gives you the proportion. The damage window is seven per cent of the swing, and the player's punishing window is longer than the warning they got.

Now consider the same attack budgeted 6 / 4 / 6. Identical damage, identical hitbox, identical arena. It plays as an instant, unavoidable slap that the boss recovers from before the player has finished flinching. Nothing about it is harder in an interesting way — the player cannot respond to 6 frames of windup, so their only remaining strategy is to stand outside the range and wait, which is the least interesting thing a fight can ask for.

The same attack, budgeted 6 / 4 / 6. 16 frames at 60fps, 0.27 seconds total: windup 6 frames (0.10s), active 4 frames (0.07s), recovery 6 frames (0.10s). The timeline marker runs at 3× slower than real time.Same hitbox, same damage. The warning has collapsed to a tenth of a second and the danger window is now a quarter of the whole action. Compare the two bars rather than the two tables — the difference is a shape, not a number.

The shape matters more than the numbers. A long windup, a tiny active window, and a recovery longer than the windup is what "fair" is made of. The player gets a long look, a hard commit, and a reward for having read it right.

The windup is the difficulty dial#

This is the single most useful thing in this shelf, so it gets its own heading.

You will want to make the boss harder. Every instinct points at damage, health, and hitbox size. All three are the wrong dial. More damage means the same fight kills you sooner. More health means the same fight takes longer. A bigger hitbox means the fight is the same fight with a worse camera.

Shorten the windup by ten frames and the fight is a different fight. 24 frames of windup is a readable attack: the player has 0.4 seconds to see a raised arm and get out. 14 frames is 0.23 seconds, which is around ordinary human reaction time for a visual cue you were already expecting — so the attack becomes something you dodge on prediction rather than on sight. 6 frames is something you dodge by having memorised the boss's attack order.

Those are three genuinely different games, and you moved one integer to travel between them. Both directions are legitimate design — a rhythm-memorisation boss is a real thing people love — but you should arrive there on purpose.

Before you touch anything else about a boss that feels wrong, change the windup and play it again. Write down the number you started from.

Build the wrong one first#

Take the boss from Part 01. Give it this shape if it does not have it already:

Boss (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D          # the boss's own body
├── AnimationPlayer
└── Hitbox (Area2D)
    └── CollisionShape2D      # the sword arc, disabled by default

Now the naive version. Two timers, one for the windup and one for the active window, exactly the way it reads in your head:

extends CharacterBody2D

@onready var animation_player: AnimationPlayer = $AnimationPlayer
@onready var hitbox: Area2D = $Hitbox
@onready var hitbox_shape: CollisionShape2D = $Hitbox/CollisionShape2D
@onready var windup_timer: Timer = $WindupTimer
@onready var active_timer: Timer = $ActiveTimer


func _ready() -> void:
	windup_timer.one_shot = true
	active_timer.one_shot = true
	windup_timer.timeout.connect(_on_windup_finished)
	active_timer.timeout.connect(_on_active_finished)
	hitbox.body_entered.connect(_on_hitbox_body_entered)
	hitbox_shape.disabled = true


func attack() -> void:
	animation_player.play(&"slam")
	windup_timer.start(0.4)


func _on_windup_finished() -> void:
	hitbox_shape.disabled = false
	active_timer.start(0.15)


func _on_active_finished() -> void:
	hitbox_shape.disabled = true


func _on_hitbox_body_entered(body: Node2D) -> void:
	if body.has_method("take_damage"):
		body.take_damage(1)

Type it, wire the two Timer nodes, and call attack() from wherever Part 01 called its strike. Then play it and swing at the player a dozen times.

Watch it come apart#

Three failures, and they will not arrive politely one at a time.

The hitbox refuses to open. hitbox_shape.disabled = false runs inside a signal handler that fires during the physics step, and the physics server will not accept a shape appearing mid-flush. Sometimes you get an error in the Output panel about flushing queries, sometimes the line quietly does nothing. The class reference for CollisionShape2D.disabled says it outright: this property should be changed with Object.set_deferred(). Not "consider". The fix is one call:

	hitbox_shape.set_deferred("disabled", false)

Note the property name is a string here, because you are asking the engine to perform the assignment later rather than performing it now. A typo in that string fails silently at runtime.

The hitbox is live while the sprite is still winding up. Watch it a few times and it looks correct. Alt-tab away, come back, drop a frame, run it on a slower machine, and the timing slides. Your 0.4 second timer and your slam animation started on the same frame but they are advancing on different clocks: Timer defaults to process_callback = TIMER_PROCESS_IDLE, and AnimationPlayer defaults to advancing on the render frame too, while the boss's body moves in _physics_process. Three clocks, actually. They agree on a good day.

You can force both onto the physics clock — Timer has TIMER_PROCESS_PHYSICS, AnimationMixer has ANIMATION_CALLBACK_MODE_PROCESS_PHYSICS. Do that and the drift shrinks, but the deeper problem stays: the animation and the timer both claim to know when the windup ends, and nothing forces them to agree. Change the clip from 24 frames of windup to 20 and the timer is now wrong, silently, with no error and no compiler complaint. You will find it a week later.

One swing deals triple damage. Or none. body_entered fires on the transition into the shape, not once per attack — so a player who is walking through the arc during those 9 frames can enter, exit, and enter again, and your handler runs each time. Meanwhile a player standing perfectly still inside the arc when the shape enables never enters anything, and takes no damage at all.

The natural repair is to stop using the signal and poll instead:

func _on_windup_finished() -> void:
	hitbox_shape.set_deferred("disabled", false)
	for body: Node2D in hitbox.get_overlapping_bodies():   # reads stale
		body.take_damage(1)

That does not work either, and the reason is documented on Area2D: the overlap list "is modified once during the physics step, not immediately after objects are moved". You enabled a shape a microsecond ago — deferred, so it is not even enabled yet — and asked what overlaps it. The answer is last frame's answer, which is nothing.

At this point the reflex is a fourth Timer: a short "damage cooldown" that swallows repeat hits. Try it. The fight gets worse, because now the boss's second attack sometimes lands for nothing when it follows the first too closely, and you have four numbers to keep in sync instead of three.

Two clocks, one event#

Stop and look at what you built.

There is one event in the world: the sword is in the air between frame 24 and frame 28. You described that event twice — once as keyframes in an animation clip, once as float seconds in a pair of Timer nodes — and then spent an afternoon trying to make the two descriptions agree.

They cannot be made to agree. There is no amount of tuning that survives you editing the clip. Every fix you add is a third description of the same event.

Delete one. The animation is the one that survives, because it is the one the player is actually looking at: the frame the sword appears to strike is a fact about the sprite, and the hitbox should be derived from it rather than racing it.

Remove both Timer nodes from the boss scene.

Move the attack into the clip#

Open slam in the Animation panel. Set the animation length to 0.9667 — 58 frames — and set the step field to 0.0166667 so the timeline snaps to 60ths of a second.

Add a Call Method Track targeted at the Boss node. Right-click the track at the frame-24 mark (0.4) and insert a key calling _enable_hitbox. Insert another at frame 28 (0.4667) calling _disable_hitbox. Those two methods live on the boss:

func _enable_hitbox() -> void:
	hitbox_shape.set_deferred("disabled", false)


func _disable_hitbox() -> void:
	hitbox_shape.set_deferred("disabled", true)

You could keyframe Hitbox/CollisionShape2D:disabled as an ordinary property track instead, and plenty of projects do. It works most of the time. It is also a direct assignment to the one property whose documentation tells you not to assign directly, so when it fails it fails in the confusing way rather than the loud way. Two extra methods buy you a version that is correct by the book, and gives you somewhere to put a sound effect later.

Then, in _ready():

	animation_player.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_PHYSICS

This is the line that makes the whole idea hold together. By default an AnimationMixer advances on the render frame, so on a machine rendering 144 frames per second your hitbox opens between two physics ticks and closes between two others, while move_and_slide() and the Area2D overlap test both run on the physics tick. Setting PHYSICS puts the frame the hitbox opens and the frame the world is simulated on the same clock. A 4-frame active window is 4 physics frames, on every machine.

The other callback mode, which will bite you in Part 04#

There is a second one, and it is not the same thing:

	animation_player.callback_mode_method = AnimationMixer.ANIMATION_CALLBACK_MODE_METHOD_IMMEDIATE

callback_mode_method governs when a Call Method Track key actually calls your method. The default is DEFERRED, which batches the calls and runs them after events are processed. The documentation is explicit about why: it "avoids bugs involving deleting nodes or modifying the AnimationPlayer while playing".

For the hitbox that default is harmless — set_deferred was going to wait for the end of the frame anyway. It stops being harmless the moment a method track spawns a projectile, because the projectile now appears one frame after the keyframe you placed it on. At 60fps that is a 16ms disagreement between where the sprite's hand is and where the fireball comes from, which is small enough to look like bad art rather than a timing bug.

IMMEDIATE fixes that, and hands back exactly the crash class the default was guarding: a method track that calls queue_free() on the boss, or swaps the playing animation, now does it in the middle of the animation update. Leave it on DEFERRED here. Remember this section exists — when Part 03's phase transition frees something from a keyframe, this is the paragraph you come back to.

The looping trap that costs an evening#

Wire the end of the attack:

func _ready() -> void:
	animation_player.animation_finished.connect(_on_animation_finished)


func _on_animation_finished(anim_name: StringName) -> void:
	if anim_name == &"slam":
		animation_player.play(&"idle")

Run it. The boss slams and then stands frozen in its final recovery frame forever. No error. No warning. The Output panel is empty.

animation_finished lives on AnimationMixer, and its documentation contains one sentence that explains an enormous number of lost evenings: this signal is not emitted if an animation is looping. If you set slam to loop while you were scrubbing the timeline to check the pose — and you did, everyone does — the handler never runs, and nothing anywhere reports a problem.

The await version of the same mistake is worse:

func attack() -> void:
	animation_player.play(&"slam")
	await animation_player.animation_finished    # hangs forever on a looping clip
	animation_player.play(&"idle")

That does not skip a step. It suspends the function permanently, at that line, with no error and no stack trace, because await on a signal that never fires waits patiently until the heat death of the process. The boss is not stuck in a bad state — the code that would leave the state never resumed at all.

The habit worth building: when an animation-driven thing stops halfway, check the loop toggle in the Animation panel before you read a single line of your own code. If you want it in the Output panel, ask the clip directly:

	var clip: Animation = animation_player.get_animation(&"slam")
	print(clip.loop_mode == Animation.LOOP_NONE)   # must be true for the signal to fire

idle loops. slam does not. Every attack clip in this shelf is LOOP_NONE.

One hit per swing#

Now the damage. The rule you want is one hit per body per swing, and the signal gives you one hit per entry, so you supply the difference: remember who has already been hit, and forget them when the next swing starts.

var _already_hit: Array[Node2D] = []


func attack() -> void:
	_already_hit.clear()
	animation_player.play(&"slam")


func _on_hitbox_body_entered(body: Node2D) -> void:
	if _already_hit.has(body):
		return
	if not body.has_method("take_damage"):
		return
	_already_hit.append(body)
	body.take_damage(SLAM_DAMAGE)

Two decisions in there are worth stating out loud.

Clear at the start, not at the end. Clearing on animation_finished reads more naturally and breaks the first time an attack is interrupted — the boss gets staggered mid-swing, the clear never runs, and the next swing cannot hit the same player at all. Clearing on the way in is correct no matter how the previous attack ended.

The parameter is typed Node2D, and it has to be. body_entered passes Node2D because, in the engine's own words, the body can be a PhysicsBody2D or a tilemap. Type it as CharacterBody2D because that is what you expect the player to be, and the script dies with a type error the first time the sword arc sweeps across a piece of level geometry with collision on it. That is not a hypothetical; it happens on the first slam near a wall.

An Array with has() is a linear scan. At the scale of a boss fight — one player, maybe a handful of destructible props — that is free, and it reads better than the alternative. If a sweep ever legitimately touches dozens of bodies, a Dictionary keyed by the body gives you constant-time lookup for the cost of a slightly uglier line.

Layers and masks point one way#

The hitbox will now do nothing, and it will look completely correct in the inspector while doing nothing. This one is worth internalising because it never stops happening.

Detection is directional. For the boss's Hitbox to notice the player, the player's collision_layer must be present in the hitbox's collision_mask. The hitbox's own collision_layer has no bearing on what the hitbox can see — it only controls who can see the hitbox.

The hitbox sees the player — not the reverse. 5 collision layers: 1 player, 2 boss, 3 boss_attack, 4 player_attack, 5 hurtbox. Boss/Hitbox sits on layer 3 (boss_attack) and masks layer 1 (player). Player sits on layer 1 (player) and masks layer 2 (boss). Boss/Hitbox detects Player, because Player's layer 1 (player) is ticked in Boss/Hitbox's mask. Player cannot detect Boss/Hitbox: its mask does not include layer 3 (boss_attack). Detection runs one way only."I am" and "I look for" are different sentences, and the bug is always that they got written as the same one. Follow the arrow: it starts at a mask and ends at a layer, and there is no arrow coming back.

Part 01 put both bodies on one layer named world and both attacks on one layer named hitbox, and that held because Part 01's hurtboxes only ever asked "is this a hitbox, and does it belong to somebody else?" This part asks a different question. body_entered hands you bodies, so the boss's hitbox has to tell the player's body from the boss's own, and a single shared world layer cannot express that. Re-do Layer Names → 2D Physics once, now, rather than discovering the drift as a bug:

LayerNameWho sits on it
1playerthe Player body
2bossthe Boss body
3boss_attackthe boss's Hitbox
4player_attackthe player's swing hitbox
5hurtboxPlayer/Hurtbox, Boss/Hurtbox

The two bodies still have to block each other, so the player's body masks 2 and the boss's masks 1 — miss that and they walk through one another. The hurtboxes now mask the opposing attack layer instead of Part 01's single hitbox layer, which is a second line of defence behind the source check you wrote there: a boss hurtbox that only looks at layer 4 cannot see the boss's own slam at all.

With those names in place:

  • Hitbox.collision_mask → layer 1 on. This is the line that makes it work.
  • Hitbox.collision_layer → layer 3 on, or nothing at all. This is only for other things querying your hitbox.

The classic failure is setting both collision_layer and collision_mask on the hitbox to the same layer, which produces an area that watches the layer it lives on and therefore sees everything except the thing you wanted. Set them from code if the editor grid keeps confusing you — the intent survives better in words:

	hitbox.set_collision_layer_value(3, true)    # I am a boss attack
	hitbox.set_collision_mask_value(1, true)     # I look for players

Read those two lines out loud. "I am" and "I look for" are different sentences, and the bug is always that they got written as the same one.

Make the hit feel like a hit#

Two small things, and they do more for the fight than any number you have tuned so far.

Hit-stop. Freeze the world for a handful of frames on contact. The whole industry does it, almost nobody notices it, and everybody feels its absence.

@export var hit_stop_frames: int = 4

var _hit_stop_active: bool = false


func _hit_stop() -> void:
	if _hit_stop_active:
		return
	_hit_stop_active = true
	Engine.time_scale = 0.0
	var seconds: float = float(hit_stop_frames) / 60.0
	await get_tree().create_timer(seconds, true, false, true).timeout
	Engine.time_scale = 1.0
	_hit_stop_active = false

The three booleans on create_timer() are process_always, process_in_physics, and ignore_time_scale, and the last one is load-bearing. With Engine.time_scale at zero, a timer that respects time scale never advances, so you would freeze the game permanently on the first successful hit. It also has to stay off the physics clock here, because at time_scale = 0 there are no physics steps to count.

Be honest about what this costs: Engine.time_scale is global, and restoring it to 1.0 assumes nothing else in the game owns it. The moment you add a slow-motion power or a pause menu that ramps time, this function starts fighting them. The per-object alternative is to stop only the two combatants — setting AnimationMixer.active to false on each and skipping their movement — which is more code and does not sell the impact nearly as well. Global is the right first answer; know that it is a first answer.

A flash. Tween the alpha of whatever got hit, on the sub-property path rather than the whole property:

var _flash_tween: Tween = null


func _flash(target: CanvasItem) -> void:
	if _flash_tween != null and _flash_tween.is_valid():
		_flash_tween.kill()
	target.modulate.a = 1.0
	_flash_tween = target.create_tween()
	_flash_tween.set_process_mode(Tween.TWEEN_PROCESS_PHYSICS)
	_flash_tween.tween_property(target, "modulate:a", 0.15, 0.05)
	_flash_tween.tween_property(target, "modulate:a", 1.0, 0.10)

Four things in nine lines, each of which is a bug you are not going to have.

"modulate:a" animates only the alpha channel. The property argument of tween_property() is a NodePath, which is what makes the :a suffix legal, and it matters because the thing you hit almost certainly runs its own hurt-flash that tints modulate red. Two tweens writing to modulate fight and the last writer wins; one writing modulate:a and one writing modulate:r coexist. The cost of the path form is that a typo — "modualte:a" — fails at runtime, not when the script parses.

target.create_tween(), not SceneTree.create_tween(). A tween from the scene tree is bound to nothing and, in the engine's words, may keep working until there is nothing left to animate — so it happily writes to a freed player for the rest of the level. Node.create_tween() binds the tween to the node it was created from, and the node dying kills the tween. Create it from the thing being animated, not from the thing that decided to animate it.

Keeping the handle and calling kill() is what stops two overlapping flashes from leaving the sprite at 15% alpha forever. Tweens are not reusable — the documentation says trying gives undefined behaviour — so this creates a fresh one every hit and kills the old one, which is the intended shape.

TWEEN_PROCESS_PHYSICS is here because the default is the render frame, and this tween runs alongside a body being moved on the physics frame. It matters much more when a tween writes position on a physics body; on alpha it is consistency rather than correctness. Being consistent about it means you never have to remember which case you are in.

Reaching into the player's node from the boss's script is a shortcut. The flash belongs to the thing being hit, and the honest home for it is a take_damage() that does its own feedback, or a signal bus that both sides listen to. It is written this way here so the tween lesson has somewhere to live. Move it as soon as you have a better place to put it.

The whole script#

Everything above, assembled:

class_name Boss
extends CharacterBody2D

const SLAM_DAMAGE: int = 1

@export var hit_stop_frames: int = 4

@onready var animation_player: AnimationPlayer = $AnimationPlayer
@onready var sprite: Sprite2D = $Sprite2D
@onready var hitbox: Area2D = $Hitbox
@onready var hitbox_shape: CollisionShape2D = $Hitbox/CollisionShape2D

var _already_hit: Array[Node2D] = []
var _flash_tween: Tween = null
var _hit_stop_active: bool = false


func _ready() -> void:
	animation_player.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_PHYSICS
	animation_player.animation_finished.connect(_on_animation_finished)
	hitbox.body_entered.connect(_on_hitbox_body_entered)
	hitbox.set_collision_layer_value(3, true)
	hitbox.set_collision_mask_value(1, true)
	hitbox_shape.disabled = true
	animation_player.play(&"idle")


func attack() -> void:
	_already_hit.clear()
	animation_player.play(&"slam")


# Called from Call Method Track keys on "slam", at frames 24 and 28.
func _enable_hitbox() -> void:
	hitbox_shape.set_deferred("disabled", false)


func _disable_hitbox() -> void:
	hitbox_shape.set_deferred("disabled", true)


func _on_animation_finished(anim_name: StringName) -> void:
	if anim_name == &"slam":
		animation_player.play(&"idle")


func _on_hitbox_body_entered(body: Node2D) -> void:
	if _already_hit.has(body):
		return
	if not body.has_method("take_damage"):
		return
	_already_hit.append(body)
	body.take_damage(SLAM_DAMAGE)
	_flash(body)
	_hit_stop()


func _flash(target: CanvasItem) -> void:
	if _flash_tween != null and _flash_tween.is_valid():
		_flash_tween.kill()
	target.modulate.a = 1.0
	_flash_tween = target.create_tween()
	_flash_tween.set_process_mode(Tween.TWEEN_PROCESS_PHYSICS)
	_flash_tween.tween_property(target, "modulate:a", 0.15, 0.05)
	_flash_tween.tween_property(target, "modulate:a", 1.0, 0.10)


func _hit_stop() -> void:
	if _hit_stop_active:
		return
	_hit_stop_active = true
	Engine.time_scale = 0.0
	var seconds: float = float(hit_stop_frames) / 60.0
	await get_tree().create_timer(seconds, true, false, true).timeout
	Engine.time_scale = 1.0
	_hit_stop_active = false

Note the shape of the thing. There is exactly one switch for the hitbox — the shape's disabled flag — and exactly one authority on when it flips. Area2D.monitoring is a second switch for the same idea, and toggling both is the two-clocks bug in miniature: two ways to turn one thing off, which means a state where one of them is wrong. Pick the shape and leave monitoring alone.

Set the windup to 14 frames — move the _enable_hitbox key to 0.2333 and the disable key to 0.30 — and play the fight. Then put them back. That is the whole difficulty dial, and you have now felt both ends of it.

One clip, two directions#

One more thing the AnimationPlayer gives you for free, because it saves an entire second clip.

The boss finishes its windup, holds the raised-arm pose, and then the player does something that should cancel the attack — steps behind it, staggers it, breaks a weak point. The boss needs to lower its arm. The naive answer is a wind_down clip that you hand-animate as the reverse of wind_up and then keep in sync forever.

play() takes a custom_speed, and it accepts negatives:

func cancel_windup() -> void:
	if animation_player.current_animation != &"wind_up":
		return
	var at: float = animation_player.current_animation_position
	animation_player.play(&"wind_up", -1.0, -1.0, true)
	animation_player.seek(at, true)

The arguments are name, custom_blend, custom_speed, from_end. A speed of -1.0 runs the clip backwards; from_end starts it at the final frame, which is where a backwards playthrough has to begin. The seek() afterwards is what makes it usable from a held pose rather than only from the very end — it drops the playhead back where the boss actually was, and the clip runs backwards from there. play_backwards(&"wind_up") is the shorthand for the same thing when starting at the end is what you wanted.

One clip, one set of keyframes, two directions. Retime the windup and the wind-down retimes with it, because there is only one description of the motion. Same principle as the rest of this part.

Checkpoint — definition of done#

  • The boss's slam plays with no Timer node anywhere in the boss scene
  • Standing still inside the sword arc when the hitbox opens takes exactly one hit
  • Walking in and out of the arc during the active window takes exactly one hit
  • Walking through the arc during the windup takes zero hits
  • Slamming with a wall or floor inside the arc does not error in the Output panel
  • The boss returns to idle on its own after every slam, from every starting position
  • Dragging the two method keys from frame 24 to frame 14 makes the attack noticeably unfair, and you moved nothing else
  • Hitting the player freezes both of them for about four frames, and the game resumes
  • You can explain: why does deleting the Timer nodes fix a bug that adding a fourth Timer could not?

Stretch (no instructions)#

Give the slam a second active window — a follow-up sweep at frames 40 to 44 — and decide what _already_hit should mean now. One hit per swing, or one hit per window? Both are defensible. Write down which one your fight wants before you write the code.

Then add an audio cue on a third method track key at frame 18, six frames before the hitbox opens. Play the fight and notice how much more readable a 24-frame windup becomes when the last 6 frames of it make a noise. Ask yourself whether you would still need 24 frames.

If you get stuck#

  • Hitbox never damages anything, no errors → check collision_mask on the Hitbox, not collision_layer. The player's layer number must be ticked in the hitbox's mask.
  • Hitbox never damages anything, and the mask is right → the target has no take_damage method, so the guard in _on_hitbox_body_entered returns before doing anything. Print body.name above the guard to confirm the signal is firing at all.
  • Output panel complains about flushing queries → a direct disabled = false survived somewhere. Every write to that property from a signal handler or a physics callback must go through set_deferred.
  • Boss freezes in its last recovery frame and never returns to idle → slam is set to loop. animation_finished is not emitted for looping animations, so your handler never ran.
  • attack() runs once and the boss never attacks again → an await on animation_finished somewhere is suspended forever on a looping clip. Same root cause, silent instead of visible.
  • Damage lands two or three times per swing → _already_hit is being cleared somewhere other than the top of attack(), or the method track key that disables the hitbox is missing and the shape stays enabled into the recovery.
  • Type error mentioning a tilemap or a StaticBody2D → the body_entered handler is typed narrower than Node2D.
  • The whole game freezes permanently the first time the boss connects → the hit-stop timer was created without ignore_time_scale, so it is waiting on a clock you set to zero.
  • Hit-stop ends early when a swing connects with two bodies at once → the _hit_stop_active guard is missing or misplaced, so the second call restores time_scale to 1.0 while the first one is still waiting on its timer.
  • Flashed sprite stays semi-transparent → two flashes overlapped and the earlier tween was not killed, or the tween was created from get_tree() instead of from the node.
  • Hitbox timing looks right in the editor and wrong in an exported build → callback_mode_process was never set to PHYSICS, and the export is rendering at a different frame rate than the editor.

Next: Part 03 — Phases — a state that contains states. Read it once this single attack feels fair on its own, because a phase change that shortens a windup is only interesting when the original windup was worth reading.