Skip to content

M3.3 — The Attack

What you'll learn

  • Add an Attack state 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 Idle on 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 Attack state 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: Attack simply 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:

  1. Enable on enter, disable on finish. Turn the hitbox on when Attack begins and off when the animation reports finished. Simple; the whole animation is "active." Good enough to start.
  2. Frame-accurate via an AnimationPlayer call-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:

if Input.is_action_just_pressed("attack"):
    transitioned.emit(&"Attack")
    return

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

  1. Build the player's attack hitbox. Open player.tscn. Add a child Node2D to Player, renamed AttackPivot (this is what you flip with facing). Drag hitbox.tscn (M3.1) onto AttackPivot; position its shape in front of the character (e.g., offset +x). Set the hitbox's layer to player_hitbox (6) and damage to a starting value (e.g., 5). Disable its CollisionShape2D by default (check Disabled) so it starts inactive.
  2. Confirm the enemy-side detection will work: enemy hurtboxes (M4) will mask player_hitbox. Nothing to set on the player now beyond the layer.
  3. Add an attack animation to the player's SpriteFrames (M2.2): a short, non-looping swing. Looping off is essential — the state relies on animation_finished firing.
  4. Create res://scripts/states/player_attack.gd with the state above. Add a child Node named Attack under the player's StateMachine, attach the script. (It is now a sibling of Idle and Move.)
  5. Add the is_action_just_pressed("attack") check to both player_idle.gd and player_move.gd as shown.
  6. Press F5. Move, then click (your M1.3 attack action): the character stops, plays the swing, and returns to idle/move when it finishes. Turn left and attack; the swing should reach left.
  7. Verify damage once M4 exists, or temporarily drop an enemy-hurtbox-layer Area2D with a Hurtbox script in front of the player and confirm it reports hurt only 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.