M3.3 — The Attack¶
What you'll learn
- Add an
Attackstate to the player FSM (M2.4) that plays the swing and gates the hitbox. - Enable the hitbox during active frames only, using the animation as the timing source.
- Orient the hitbox to the player's facing, and return to
Idleon animation finish.
How it applies
- Active frames are the difference between fair and broken combat. A hitbox that is always on damages anything the player brushes; a hitbox enabled only during the swing's active frames makes attacking a deliberate act with timing. Players read the windup; the hit lands on the frames the animation shows the blade out. Mistime the gate and the game either can't hit or hits constantly.
- Attack commitment is a design lever. While in the
Attackstate the player can be made unable to move or re-attack until the swing finishes — "commitment" that gives enemies a window and makes positioning matter. The FSM is what makes this expressible:Attacksimply doesn't accept move input. - Facing must drive the hitbox, or attacks miss visually. The swing has to reach toward where the player faces; an attack box frozen to one side feels broken when the player turns. Tying the hitbox's placement to facing keeps the swing legible.
- Reuse forward. The same enable-hitbox-during-active-frames pattern is exactly what M4's enemies use for their attacks. Build it once on the player; enemies inherit the shape.
Concepts¶
The attack as a state¶
M2.4 gave the player a state machine where exactly one state runs at a time. The attack is a new state,
not a flag: entering Attack plays the swing and turns on the hitbox; leaving it turns the hitbox off.
Because it is a state, "you can't move while attacking" is automatic — Attack simply doesn't process
movement input. Idle and Move gain one line each: if the attack action just fired, transition to
Attack.
Gating the hitbox with the animation¶
The attack hitbox must be live only during the swing's active frames — the part of the animation where the blade is actually out, not the windup or recovery. Two common ways to gate it:
- Enable on enter, disable on finish. Turn the hitbox on when
Attackbegins and off when the animation reports finished. Simple; the whole animation is "active." Good enough to start. - Frame-accurate via an
AnimationPlayercall-method track. Keyframe two function calls on the attack track —_enable_hitbox()on the first active frame,_disable_hitbox()after the last — so the damaging window matches the art exactly. This is the production approach.
This book starts with approach 1 for clarity, then notes how to tighten to approach 2. Either way, the animation is the timing source — the hitbox window is defined by the animation, not by a hand-counted timer that drifts from the art.
A hitbox is "off" by disabling its monitoring/monitorable or its collision shape. Toggling the
CollisionShape2D.disabled flag (deferred, to be safe inside physics callbacks) is the reliable way:
when disabled, the shape reports no overlaps, so the hurtbox never sees it.
Orienting toward facing¶
The player's facing was tracked in M2.2 (flip_h for left/right). The attack hitbox should sit on the
side the player faces. The simplest correct approach: keep the hitbox as a child of a pivot that you
rotate or flip with facing, or set the hitbox's local x-offset sign to match flip_h. For a four- or
eight-direction game you would point the hitbox along the last movement direction. Keep it readable: the
swing must visibly reach toward where the character is aimed.
Example
An Attack state using approach 1. On enter, play the swing and enable the hitbox; when the
AnimatedSprite2D reports the (non-looping) attack animation finished, disable the hitbox and return
to Idle:
# res://scripts/states/player_attack.gd
extends State
@onready var player: CharacterBody2D = owner
@onready var hitbox: Hitbox = player.get_node("AttackPivot/Hitbox")
func enter() -> void:
player.velocity = Vector2.ZERO # commit: no sliding during the swing
_orient_hitbox()
_set_hitbox_active(true)
player.sprite.play("attack")
player.sprite.animation_finished.connect(_on_finished, CONNECT_ONE_SHOT)
func exit() -> void:
_set_hitbox_active(false)
func physics_update(_delta: float) -> void:
player.move_and_slide() # velocity is zero; this just resolves residual motion
func _on_finished() -> void:
transitioned.emit(&"Idle")
func _set_hitbox_active(on: bool) -> void:
hitbox.get_node("CollisionShape2D").set_deferred("disabled", not on)
func _orient_hitbox() -> void:
var pivot := player.get_node("AttackPivot") as Node2D
pivot.scale.x = -1.0 if player.sprite.flip_h else 1.0
CONNECT_ONE_SHOT auto-disconnects after the animation finishes once, so the handler doesn't stack up
across repeated attacks. set_deferred toggles the shape safely from within physics processing.
Example
Letting Idle and Move start an attack is one added check each. In Move.physics_update (and the
same in Idle), before the movement logic:
is_action_just_pressed (M1.3) makes one click one swing. The return hands control to the machine
immediately so the rest of the move logic doesn't also run this frame.
Tightening to frame-accurate (approach 2)¶
To match the damage window to the art exactly: replace the AnimatedSprite2D flipbook attack with an
AnimationPlayer attack track (or keep the sprite and add an AnimationPlayer alongside), and add a
call-method track with _set_hitbox_active(true) keyed on the first active frame and
_set_hitbox_active(false) keyed after the last. The state then just plays the animation; the track
drives the gate. This is worth doing once attacks have distinct windup/active/recovery phases that should
not all deal damage.
Walkthrough¶
- Build the player's attack hitbox. Open
player.tscn. Add a childNode2DtoPlayer, renamedAttackPivot(this is what you flip with facing). Draghitbox.tscn(M3.1) ontoAttackPivot; position its shape in front of the character (e.g., offset +x). Set the hitbox's layer toplayer_hitbox(6) anddamageto a starting value (e.g.,5). Disable itsCollisionShape2Dby default (check Disabled) so it starts inactive. - Confirm the enemy-side detection will work: enemy hurtboxes (M4) will mask
player_hitbox. Nothing to set on the player now beyond the layer. - Add an
attackanimation to the player'sSpriteFrames(M2.2): a short, non-looping swing. Looping off is essential — the state relies onanimation_finishedfiring. - Create
res://scripts/states/player_attack.gdwith the state above. Add a childNodenamedAttackunder the player'sStateMachine, attach the script. (It is now a sibling ofIdleandMove.) - Add the
is_action_just_pressed("attack")check to bothplayer_idle.gdandplayer_move.gdas shown. - Press
F5. Move, then click (your M1.3attackaction): the character stops, plays the swing, and returns to idle/move when it finishes. Turn left and attack; the swing should reach left. - Verify damage once M4 exists, or temporarily drop an enemy-hurtbox-layer
Area2Dwith aHurtboxscript in front of the player and confirm it reportshurtonly during the swing, never while idle.
Optional sanity check
Temporarily print in _set_hitbox_active: print("hitbox active: ", on). Attack a few times and
confirm you see exactly one true then one false per swing — never two true in a row (which would
mean the one-shot connection leaked) and never true while idle (which would mean the default-disabled
state was wrong). Remove the print.
Self-check quiz¶
Q1 — Why is the attack animation set to non-looping, and what breaks if it loops?
A. Looping animations can't play on an AnimatedSprite2D.
B. The state returns to Idle on animation_finished; a looping animation never finishes, so the
player would be stuck attacking forever with the hitbox active.
C. Looping doubles the damage.
D. Non-looping animations render faster.
Reveal answer
B. The Attack state exits when the animation reports finished; a looping animation never
emits that, so the state never transitions out — the player is frozen mid-attack and the hitbox
stays live. A is false. C is not how it works. D is irrelevant.
Q2 — Why enable the hitbox only during the attack's active frames rather than leaving it always on?
A. Always-on hitboxes use more memory. B. An always-on hitbox damages anything the player merely touches, removing timing and fairness; a gated hitbox makes the hit land only during the swing the animation shows. C. The engine disables idle hitboxes automatically. D. Always-on hitboxes can't carry a damage value.
Reveal answer
B. Gating is what makes attacking a deliberate, timed act instead of a damage aura. An always-on
hitbox would hurt enemies the player walks past, which is neither fair nor readable. A is
negligible. C is false — you disable it; nothing is automatic. D is false (the damage value is
independent of whether the box is active).
Q3 — Why connect to animation_finished with CONNECT_ONE_SHOT?
A. To make the animation play once. B. So the handler auto-disconnects after firing once, preventing duplicate connections from stacking across repeated attacks. C. Because signals can only be connected once. D. To speed up the connection.
Reveal answer
B. Each enter() connects the finish handler; without one-shot, every attack adds another
live connection, so after N attacks the handler runs N times and the transitions multiply.
CONNECT_ONE_SHOT disconnects it after the single firing, matching the one-attack lifetime. A
confuses the loop flag (set on the animation) with the connection. C is false. D is irrelevant.
Integration question¶
Q4 — open
The attack ties together four prior pieces: the FSM (M2.4), the facing from M2.2, the Hitbox component
(M3.1), and the attack action (M1.3). Walk the full path from the player pressing the attack button
to an enemy losing HP, naming what each piece contributes, and explain specifically why making
Attack a state (rather than an is_attacking flag) makes "the player can't move mid-swing" and
"one click is one swing" fall out naturally.
Reveal expected answer
The path: the attack action (M1.3) fires; Idle/Move detect it via is_action_just_pressed
and emit &"Attack", so the FSM (M2.4) enters the Attack state; enter() orients the
AttackPivot by the M2.2 facing (flip_h), enables the Hitbox component's shape (M3.1), and plays
the non-looping swing; while the shape is enabled, an overlapping enemy Hurtbox (M3.1) detects
the hitbox, reads its damage, and emits hurt, which the enemy's Health (M3.2) consumes,
dropping HP. When the animation finishes, the state disables the hitbox and returns to Idle.
Making Attack a state rather than a flag is what makes the constraints free: the Attack state's
physics_update doesn't read movement input, so the player simply cannot move during the
swing — no if not is_attacking guard scattered through the move code — and because entering a
state is a discrete event driven by is_action_just_pressed, one button press produces exactly one
entry into Attack and therefore one swing, with re-entry impossible until the state exits to
Idle. The single-active-state guarantee from M2.4 is doing the work that a tangle of flags would
otherwise have to enforce by hand.