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:
| Phase | Frames | Seconds | What the player is doing |
|---|---|---|---|
| Windup | 24 | 0.400 | reading the pose, deciding, moving |
| Active | 4 | 0.067 | already committed, one way or the other |
| Recovery | 30 | 0.500 | punishing, or getting back to neutral |
58 frames total. Slightly under a second, and only 4 of those frames can hurt anybody.
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 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.
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:
| Layer | Name | Who sits on it |
|---|---|---|
| 1 | player | the Player body |
| 2 | boss | the Boss body |
| 3 | boss_attack | the boss's Hitbox |
| 4 | player_attack | the player's swing hitbox |
| 5 | hurtbox | Player/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
slamplays with noTimernode 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
idleon 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
Timernodes fix a bug that adding a fourthTimercould 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_maskon theHitbox, notcollision_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_damagemethod, so the guard in_on_hitbox_body_enteredreturns before doing anything. Printbody.nameabove the guard to confirm the signal is firing at all. - Output panel complains about flushing queries → a direct
disabled = falsesurvived somewhere. Every write to that property from a signal handler or a physics callback must go throughset_deferred. - Boss freezes in its last recovery frame and never returns to idle →
slamis set to loop.animation_finishedis not emitted for looping animations, so your handler never ran. attack()runs once and the boss never attacks again → anawaitonanimation_finishedsomewhere is suspended forever on a looping clip. Same root cause, silent instead of visible.- Damage lands two or three times per swing →
_already_hitis being cleared somewhere other than the top ofattack(), 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→ thebody_enteredhandler is typed narrower thanNode2D. - 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_activeguard is missing or misplaced, so the second call restorestime_scaleto1.0while 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_processwas never set toPHYSICS, 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.