doc 2 of 5

Part 01 — What makes it a boss

You will build: A boss that idles, telegraphs one attack, strikes, and then stands open for a beat — plus a health bar that only drops during that opening, so you can watch the loop working from across the room.

A boss is not a big enemy. That sentence sounds like a slogan until you build the big enemy and stand in front of it, so that is where this part starts. You will build the honest version first — five hundred health, contact damage, walks at you — and fight it long enough to notice that nothing about it is broken and nothing about it is a fight. Then you will fix it in the obvious way, by giving it an attack, and discover that the obvious fix makes it worse. Only then does the windup mean anything.

Everything here runs on coloured rectangles. No sprites, no gravity, no level. One axis of movement, two bodies, one attack. Every part of this scene that is not the loop has been removed on purpose, because the loop is the thing you are trying to feel, and a parallax background will not help you feel it.

The arena#

Make a new scene. Root is a Node2D named Arena. Under it go two children, Player and Boss, both CharacterBody2D. Save the scene as arena.tscn and set it as the main scene.

There is no floor and no gravity. Both bodies keep their y where you put them and only ever move along x. Put the player at (200, 300) and the boss at (700, 300).

Node layout#

Arena (Node2D)
├── Player (CharacterBody2D)
│   ├── CollisionShape2D        RectangleShape2D, 32 × 48
│   ├── Body (ColorRect)        size (32, 48), position (-16, -24)
│   ├── Hurtbox (Area2D)
│   │   └── CollisionShape2D    RectangleShape2D, 32 × 48
│   └── SwingHitbox (Area2D)
│       └── CollisionShape2D    RectangleShape2D, 40 × 40, disabled = on
└── Boss (CharacterBody2D)
    ├── CollisionShape2D        RectangleShape2D, 64 × 96
    ├── Body (ColorRect)        size (64, 96), position (-32, -48)
    ├── Hurtbox (Area2D)
    │   └── CollisionShape2D    RectangleShape2D, 64 × 96
    ├── ContactDamage (Area2D)
    │   └── CollisionShape2D    RectangleShape2D, 80 × 112
    ├── SlamHitbox (Area2D)
    │   └── CollisionShape2D    RectangleShape2D, 72 × 96, disabled = on
    ├── HealthBar (Node2D)      position (-60, -80)
    │   ├── Back (ColorRect)    size (120, 10), dark grey
    │   └── Fill (ColorRect)    size (120, 10), red
    └── AnimationPlayer

A ColorRect is a Control, and a Control draws from its top-left corner. Dropping one under a CharacterBody2D without an offset puts the whole rectangle down and to the right of where the body actually is, which makes every collision look wrong even when it is correct. That is what the negative position values above are for: they pull the rectangle back so its centre sits on the body's origin.

ContactDamage is deliberately larger than the boss's own collision shape. The two bodies block each other, so a hitbox exactly the size of the boss would never be reached. The eight-pixel margin is the difference between "touching the boss hurts" and "touching the boss does nothing, ever, and you spend an hour wondering why". It also means the boss's hitbox swallows the boss's own hurtbox, which is a problem the layer numbers below cannot solve and the hitbox script after them can.

Collision layers#

Three layers is enough. Open Project Settings → Layer Names → 2D Physics and name them:

LayerNameWho sits on it
1worldPlayer and Boss bodies
2hitboxSwingHitbox, SlamHitbox, ContactDamage
3hurtboxPlayer/Hurtbox, Boss/Hurtbox

Now the part that decides whether any of this works. Layer and mask are not symmetric. An area only detects a thing when that thing's collision_layer appears in the area's collision_mask. So set it in exactly one direction:

  • Hitboxes (SwingHitbox, SlamHitbox, ContactDamage): collision_layer = 2 only, collision_mask = nothing, monitoring off, monitorable on. A hitbox is something to be found. It does no looking.
  • Hurtboxes (both): collision_layer = 3, collision_mask = 2 only, monitoring on. A hurtbox is the only thing in this scene that asks questions. Layer 3 does no work yet — nothing in this shelf ever queries a hurtbox — and it is set anyway so that something can find one later.
  • Bodies (both CharacterBody2D roots): collision_layer = 1, collision_mask = 1.

One rule for the whole fight: hurtboxes look for hitboxes that belong to somebody else. That last clause is not decoration, and the next section is where it earns its place. Contact damage is a hitbox that is always on. The slam is a hitbox that is on for a sixth of a second. Same mechanism, different uptime. Holding to one rule here is what will let you add three more attacks in Part 03 without touching the damage code.

Input#

The default ui_* actions are enough. ui_left and ui_right move; ui_accept (Space or Enter) swings. Adding a proper input map is worth doing later and is a distraction now.

The hitbox script#

Five lines, and it is the only shared vocabulary the two fighters have. Save it as hitbox.gd and attach it to SwingHitbox, SlamHitbox, and ContactDamage.

class_name Hitbox
extends Area2D

@export var damage: int = 10

## The fighter this hitbox belongs to.
@onready var source: Node2D = get_parent() as Node2D

class_name is what makes area is Hitbox legal elsewhere, and that check is how a hurtbox tells a damage source from any other area that might wander into it later — a water volume, a camera trigger, a pickup. Set damage to 10 on the swing, 25 on the slam, 8 on contact.

source answers the question the layer scheme cannot: whose hitbox is this. ContactDamage is 80 × 112 and Boss/Hurtbox is 64 × 96, both centred on the boss's origin, so the boss's hurtbox sits permanently inside the boss's own hitbox. An Area2D does not skip its siblings and does not skip areas that share its parent — to the physics server they are two shapes that overlap, and the fact that they belong to the same creature is not information it has. Without an ownership check the boss deals itself 8 damage every 0.15 seconds and falls over in about nine seconds while you stand still and watch. SlamHitbox does the same thing every time it switches on: 72 pixels wide at an offset of 56, against a body half-width of 32.

Every hitbox in this scene is a direct child of the fighter that owns it, so the parent is the honest answer and there is nothing to wire in the inspector and nothing to forget. If you ever nest a hitbox deeper than that, swap this line for an @export var source: Node2D and drag the fighter in.

The player#

Save as player.gd, attach to Player.

extends CharacterBody2D

@export var speed: float = 220.0
@export var max_health: int = 100
@export var hurt_cooldown_time: float = 0.4

## The swing hitbox stays live for this many physics frames.
const SWING_FRAMES: int = 6
const SWING_OFFSET: float = 36.0

@onready var body_rect: ColorRect = $Body
@onready var hurtbox: Area2D = $Hurtbox
@onready var swing_hitbox: Area2D = $SwingHitbox
@onready var swing_shape: CollisionShape2D = $SwingHitbox/CollisionShape2D

var health: int = 0
var facing: float = 1.0
var swing_frames_left: int = 0
var hurt_cooldown: float = 0.0

func _ready() -> void:
	health = max_health

func _physics_process(delta: float) -> void:
	var direction: float = Input.get_axis(&"ui_left", &"ui_right")
	if direction != 0.0:
		facing = 1.0 if direction > 0.0 else -1.0
	velocity.x = direction * speed
	velocity.y = 0.0
	move_and_slide()

	swing_hitbox.position.x = SWING_OFFSET * facing

	if Input.is_action_just_pressed(&"ui_accept") and swing_frames_left <= 0:
		_start_swing()
	_tick_swing()
	_tick_hurt(delta)

func _start_swing() -> void:
	swing_frames_left = SWING_FRAMES
	swing_shape.set_deferred("disabled", false)

func _tick_swing() -> void:
	if swing_frames_left <= 0:
		return
	swing_frames_left -= 1
	if swing_frames_left == 0:
		swing_shape.set_deferred("disabled", true)

func _tick_hurt(delta: float) -> void:
	if hurt_cooldown > 0.0:
		hurt_cooldown -= delta
		return
	for area: Area2D in hurtbox.get_overlapping_areas():
		if area is Hitbox and (area as Hitbox).source != self:
			_take_damage((area as Hitbox).damage)
			return

func _take_damage(amount: int) -> void:
	health -= amount
	hurt_cooldown = hurt_cooldown_time
	print("player hp: ", health)
	var tween: Tween = create_tween()
	body_rect.modulate = Color(4.0, 0.4, 0.4)
	tween.tween_property(body_rect, "modulate", Color.WHITE, 0.25)
	if health <= 0:
		health = max_health
		global_position.x = 200.0
		print("--- you died, respawning ---")

Three decisions in there are worth more than the rest of the file.

The swing lasts six physics frames, not 0.1 seconds. A Timer node looks like the natural tool and it is the wrong one at this scale. The class reference says outright that timers process once per frame and that wait times below roughly 0.05 seconds end inconsistently on an unstable framerate. A boss fight lives in that range — a three-frame parry window at 60fps is 0.05 seconds exactly. Counting frames gives you a window that is the same length every time the game runs, which is the difference between a fight players can learn and a fight that lies to them once every twenty attempts.

The shape is enabled with set_deferred, never by assignment. CollisionShape2D.disabled carries an explicit instruction in the docs: change it with set_deferred(). You are inside _physics_process, the engine is mid-step, and a direct write is either refused or produces an error in the output panel. Write swing_shape.disabled = false once, watch what happens, then put the set_deferred back. It is a two-minute lesson that saves an evening later.

Damage is polled, not signalled. area_entered fires on the transition into a shape, which means a hitbox that is already overlapping you when it switches on may or may not announce itself depending on the order things happened that frame. Polling get_overlapping_areas() sidesteps that at the cost of one frame of latency — the docs note the overlap list is rebuilt once per physics step, so a hitbox enabled this frame is visible next frame. Sixteen milliseconds. Nobody will feel it, and the code has one path instead of two. The cooldown is what stops a standing overlap from draining you sixty times a second.

Stage A: the honest big enemy#

Now the boss. This first version is the one almost everybody writes when told to make a boss, and it is worth typing out in full rather than reading about, because the point is what it feels like, and you cannot feel a code listing.

Save as boss.gd, attach to Boss. Set player_path in the inspector to point at the Player node.

extends CharacterBody2D

@export var max_health: int = 500
@export var walk_speed: float = 60.0
@export var player_path: NodePath

const STOP_DISTANCE: float = 4.0
const BAR_WIDTH: float = 120.0

@onready var player: CharacterBody2D = get_node(player_path)
@onready var body_rect: ColorRect = $Body
@onready var hurtbox: Area2D = $Hurtbox
@onready var health_fill: ColorRect = $HealthBar/Fill

var health: int = 0
var hurt_cooldown: float = 0.0

func _ready() -> void:
	health = max_health

func _physics_process(delta: float) -> void:
	var to_player: float = player.global_position.x - global_position.x
	if to_player > STOP_DISTANCE:
		velocity.x = walk_speed
	elif to_player < -STOP_DISTANCE:
		velocity.x = -walk_speed
	else:
		velocity.x = 0.0
	velocity.y = 0.0
	move_and_slide()

	if hurt_cooldown > 0.0:
		hurt_cooldown -= delta
		return
	for area: Area2D in hurtbox.get_overlapping_areas():
		if area is Hitbox and (area as Hitbox).source != self:
			_take_damage((area as Hitbox).damage)
			return

func _take_damage(amount: int) -> void:
	health -= amount
	hurt_cooldown = 0.15
	health_fill.size.x = BAR_WIDTH * (float(health) / float(max_health))
	if health <= 0:
		health = 0
		set_physics_process(false)
		body_rect.color = Color(0.2, 0.2, 0.2)

Note @onready on the node references and plain @export on the tuning numbers, on separate variables. Putting both annotations on one variable is an error in Godot 4, not a style preference — @onready assigns its default after @export has already done its work, so the inspector value gets silently thrown away, and the engine refuses to compile rather than let that happen.

Run it. Fight it for a full minute. Actually do this; the rest of the part is built on what you notice.

Here is what happens. You walk up, you hold Space, the bar goes down in tiny steps, you take contact damage, you back off, you walk in again. Fifty swings at ten damage each. You will probably win. If you lose, you will not be able to say what you did wrong, because you did not do anything — you stood in a rectangle and pressed a button, and the arithmetic went the other way.

Nothing here is a bug. The boss walks correctly, the damage lands correctly, the bar is accurate. It is boring and unbeatable at the same time, and those two feelings have the same cause.

The three questions#

At every moment in a fight worth playing, the player is asking three things, and the fight is answering them continuously:

  1. What is it doing right now?
  2. When does the dangerous thing land?
  3. Where am I safe?

Hold Stage A against those.

What is it doing? Walking. It is always walking. There is no second thing it could be doing, so the question has no content.

When does the dangerous thing land? Whenever you are inside the rectangle. Damage is a function of position only. There is no moment, so there is no timing.

Where am I safe? Anywhere that is not there. The only answer is distance, and distance is also where you cannot deal damage, so the correct play is to oscillate: step in, swing, step out. Fifty times.

A boss that answers none of the three is a resource drain with legs. The player's health bar is the fight, and the only skill being tested is whether you brought enough of it. That is why the fight feels simultaneously boring and unbeatable — those are the same complaint. When the outcome is set before you start pressing buttons, winning is tedious and losing is unfair.

So give it an attack. That is the obvious repair, and it is worth actually making before you hear why it is not enough.

Stage B: the attack with no warning#

Add a Timer child named AttackTimer to the boss. Set wait_time to 2.0 and turn one_shot off for now — you want it to keep firing. Then add this to boss.gd:

@onready var attack_timer: Timer = $AttackTimer
@onready var slam_shape: CollisionShape2D = $SlamHitbox/CollisionShape2D

func _ready() -> void:
	health = max_health
	attack_timer.timeout.connect(_on_attack_timer_timeout)

func _on_attack_timer_timeout() -> void:
	$SlamHitbox.position.x = 56.0 * (1.0 if player.global_position.x > global_position.x else -1.0)
	slam_shape.set_deferred("disabled", false)
	await get_tree().create_timer(0.18, false, true).timeout
	slam_shape.set_deferred("disabled", true)

The two extra arguments to create_timer are not decoration. The signature is create_timer(time_sec, process_always, process_in_physics, ignore_time_scale), and both defaults are wrong for a fight. process_always defaults to true, so a pending attack keeps counting down through your pause menu and lands the instant the player unpauses. process_in_physics defaults to false, so the hitbox would switch off on a render frame while the boss moves on a physics frame — two clocks measuring the same event. Passing false, true puts the delay on the same clock as everything else.

Run it. Fight it.

Twenty-five damage arrives out of a clear sky, every two seconds, forever. You cannot dodge it because there is nothing to dodge on — no sound, no pose, no movement that precedes it. The only counterplay that exists is to stand outside 56 pixels and wait, which means the optimal strategy is still distance, still oscillation, still not a fight. You have added danger and removed nothing.

Sit with the specific feeling of losing health to that slam. It is not "that was hard". It is "there was nothing I could have done". That feeling is the thing the windup exists to eliminate, and it is the reason you built Stage B instead of reading a paragraph about telegraphing. A technique adopted before the problem is felt is something you copied.

Readability means commitment#

The fix is not "make the attack slower". Slowing the slam down changes the damage-per-second and nothing else. The fix is that the boss must announce, and an announcement has a shape:

The boss holds one pose, long enough to be seen, before anything dangerous exists in the world.

Three conditions, and all three are load-bearing.

One pose. Not a sequence, not a subtle lean. A single silhouette that is different from every other silhouette the boss has. If your windup and your idle look similar, the windup does not exist, no matter how long it lasts. This is why bosses in 2D games tend toward absurd, exaggerated postures — the arm goes up past the top of the sprite, the whole body coils. Readability at a glance beats anatomical sense.

Long enough to be seen. The floor is roughly 0.25 seconds, which is human reaction time to a visual stimulus you are already expecting. Below that, the pose is technically present and functionally absent. Start at 0.7 seconds while you are learning the feel, and tighten it later when the player knows the fight.

Before anything dangerous exists. Not "before the damage number appears" — before the hitbox is enabled at all. If the pose and the hitbox overlap in time, the player who reacted correctly still gets hit, and you have taught them that reacting does not work.

And the thing that makes the announcement mean something: for the length of the windup, the boss cannot change its mind. It has picked a direction, a position, an attack. If the boss can rotate to track you during its own telegraph, the telegraph told you nothing you can act on. Commitment is not a limitation you are imposing on the boss to be nice. Commitment is what converts information into a decision.

The punish window#

Announcing is half the contract. The other half is what happens when the attack ends.

Consider two bosses with identical damage output and identical telegraphs. Boss A finishes its slam and is instantly back in idle, walking, able to attack again. Boss B finishes its slam and stands there for nine tenths of a second, planted, unable to do anything.

Boss A's damage is a tax. You pay it over the length of the fight and you reduce it by playing well, but your reward for reading the telegraph perfectly is that you took zero instead of twenty-five. Nothing you do makes the boss die faster; the boss dies on its own schedule at whatever rate you can chip at it.

Boss B's damage is a transaction. Reading the telegraph gets you a specific, bounded opportunity to hurt it. The fight now has a currency: openings. Your skill converts directly into pace. And crucially, the fight has become teachable — a player who dies to Boss B can say what they did wrong, because there were discrete moments and they mishandled one of them.

Codify this with a vulnerability rule. In this prototype, the boss takes damage only during recovery. Swings that land at any other time do nothing.

That is a strong choice and you should know what you are buying. Total invulnerability outside the window is maximally legible — the health bar becomes a scoreboard of correct reads and nothing else, which is exactly what you want while you are learning to build these. It is also brittle and can feel arbitrary in a shipped game, because the player who lands a clean hit and sees no number does not think "wrong window", they think "the hitbox is broken". The common production answer is chip damage: hits outside the window deal something small, hits inside deal full value plus a stagger.

The deciding question is not which is better. It is: does your fight have enough other feedback to make an ignored hit read as information rather than as a bug? If the boss visibly guards, flinches wrong, sparks grey instead of red — full invulnerability reads fine. If a mistimed hit produces silence, use chip damage. Build the invulnerable version now, because the difference is one line, and you cannot judge the trade-off from a description.

Stage C: the three beats#

Replace boss.gd entirely. This is the version you keep.

First, the scene changes. Delete AttackTimer and add three Timer children: WindUpTimer, StrikeTimer, RecoverTimer. Leave their properties alone for now; the script sets them, so what you type is what runs, and nothing hides in the inspector where you will forget it.

class_name Boss
extends CharacterBody2D

signal health_changed(current: int, maximum: int)
signal died

enum Beat { IDLE, WIND_UP, STRIKE, RECOVER }

@export var max_health: int = 500
@export var walk_speed: float = 60.0
@export var strike_range: float = 120.0
@export var wind_up_time: float = 0.7
@export var strike_time: float = 0.18
@export var recover_time: float = 0.9

@export var player_path: NodePath

const STOP_DISTANCE: float = 4.0
const BAR_WIDTH: float = 120.0
const SLAM_OFFSET: float = 56.0

const COLOR_IDLE: Color = Color(0.32, 0.40, 0.62)
const COLOR_WIND_UP: Color = Color(0.90, 0.55, 0.18)
const COLOR_STRIKE: Color = Color(0.92, 0.22, 0.22)
const COLOR_RECOVER: Color = Color(0.70, 0.72, 0.78)
const COLOR_DEAD: Color = Color(0.18, 0.18, 0.20)

@onready var player: CharacterBody2D = get_node(player_path)
@onready var body_rect: ColorRect = $Body
@onready var hurtbox: Area2D = $Hurtbox
@onready var slam_hitbox: Area2D = $SlamHitbox
@onready var slam_shape: CollisionShape2D = $SlamHitbox/CollisionShape2D
@onready var health_fill: ColorRect = $HealthBar/Fill
@onready var anim: AnimationPlayer = $AnimationPlayer
@onready var wind_up_timer: Timer = $WindUpTimer
@onready var strike_timer: Timer = $StrikeTimer
@onready var recover_timer: Timer = $RecoverTimer

var health: int = 0
var beat: Beat = Beat.IDLE
var facing: float = -1.0
var hurt_cooldown: float = 0.0
var flash_tween: Tween = null
var time_since_opening: float = 0.0

func _ready() -> void:
	health = max_health
	_arm(wind_up_timer, wind_up_time, _on_wind_up_finished)
	_arm(strike_timer, strike_time, _on_strike_finished)
	_arm(recover_timer, recover_time, _on_recover_finished)
	health_changed.emit(health, max_health)
	_enter_idle()

## Every one of these must be one_shot, or the beat repeats under itself forever.
func _arm(timer: Timer, seconds: float, handler: Callable) -> void:
	timer.one_shot = true
	timer.wait_time = seconds
	timer.process_callback = Timer.TIMER_PROCESS_PHYSICS
	timer.timeout.connect(handler)

func _physics_process(delta: float) -> void:
	time_since_opening += delta

	if beat == Beat.IDLE:
		_approach()
		if global_position.distance_to(player.global_position) <= strike_range:
			_enter_wind_up()
	else:
		# Committed. The boss does not reposition, retarget, or reconsider.
		velocity.x = 0.0

	velocity.y = 0.0
	move_and_slide()
	_tick_hurt(delta)

func _approach() -> void:
	var to_player: float = player.global_position.x - global_position.x
	if to_player > STOP_DISTANCE:
		velocity.x = walk_speed
		facing = 1.0
	elif to_player < -STOP_DISTANCE:
		velocity.x = -walk_speed
		facing = -1.0
	else:
		velocity.x = 0.0

# --- the loop -------------------------------------------------------------

func _enter_idle() -> void:
	beat = Beat.IDLE
	body_rect.color = COLOR_IDLE
	anim.play(&"idle")

func _enter_wind_up() -> void:
	beat = Beat.WIND_UP
	velocity.x = 0.0
	facing = 1.0 if player.global_position.x > global_position.x else -1.0
	slam_hitbox.position.x = SLAM_OFFSET * facing
	body_rect.color = COLOR_WIND_UP
	anim.play(&"wind_up")
	wind_up_timer.start()

func _on_wind_up_finished() -> void:
	beat = Beat.STRIKE
	body_rect.color = COLOR_STRIKE
	anim.play(&"slam")
	slam_shape.set_deferred("disabled", false)
	strike_timer.start()

func _on_strike_finished() -> void:
	beat = Beat.RECOVER
	slam_shape.set_deferred("disabled", true)
	body_rect.color = COLOR_RECOVER
	anim.play(&"recover")
	print("opening — %.2f s since the last one" % time_since_opening)
	time_since_opening = 0.0
	recover_timer.start()

func _on_recover_finished() -> void:
	_enter_idle()

# --- damage ---------------------------------------------------------------

func _tick_hurt(delta: float) -> void:
	if hurt_cooldown > 0.0:
		hurt_cooldown -= delta
		return
	for area: Area2D in hurtbox.get_overlapping_areas():
		if area is Hitbox and (area as Hitbox).source != self:
			hurt_cooldown = 0.15
			if beat == Beat.RECOVER:
				_take_damage((area as Hitbox).damage)
			else:
				_guard()
			return

func _take_damage(amount: int) -> void:
	health -= amount
	health_changed.emit(health, max_health)
	_flash(Color(3.0, 3.0, 3.0))
	_float_text(str(amount), Color(1.0, 0.85, 0.3))

	var ratio: float = float(health) / float(max_health)
	var bar_tween: Tween = create_tween()
	bar_tween.tween_property(health_fill, "size:x", BAR_WIDTH * ratio, 0.25)

	if health <= 0:
		health = 0
		_die()

func _guard() -> void:
	_flash(Color(0.55, 0.55, 0.65))
	_float_text("guarded", Color(0.6, 0.62, 0.7))

func _die() -> void:
	died.emit()
	body_rect.color = COLOR_DEAD
	set_physics_process(false)
	slam_shape.set_deferred("disabled", true)

# --- feedback -------------------------------------------------------------

func _flash(tint: Color) -> void:
	if flash_tween != null and flash_tween.is_valid():
		flash_tween.kill()
	body_rect.modulate = tint
	flash_tween = create_tween()
	flash_tween.tween_property(body_rect, "modulate", Color.WHITE, 0.18)

func _float_text(text: String, tint: Color) -> void:
	var label: Label = Label.new()
	label.text = text
	label.modulate = tint
	label.position = Vector2(-20.0, -70.0)
	add_child(label)

	var tween: Tween = label.create_tween()
	tween.set_parallel(true)
	tween.tween_property(label, "position:y", label.position.y - 30.0, 0.6)
	tween.tween_property(label, "modulate:a", 0.0, 0.6)
	tween.finished.connect(label.queue_free)

The animations#

Add three clips to the AnimationPlayer, plus a looping idle. They can be crude — you are keying a rectangle.

  • idle — 1.0s, loop on. Key Body:position:y from -48 to -44 and back. A tiny breath so the boss does not look frozen.
  • wind_up — 0.7s, loop off. Key Body:position:y from -48 to -62 over the first 0.5s and hold. The boss rears.
  • slam — 0.18s, loop off. Key Body:position:y from -62 to -40 in the first two frames, then hold. Fast is the point.
  • recover — 0.9s, loop off. Key Body:position:y from -40 back to -48 slowly. The boss is heavy and it is getting up.

Turn looping off on the three attack clips and leave it on for idle. Remember that: animation_finished is never emitted for a looping animation, which is stated plainly in the class reference and is the single most common way boss logic silently stops running. Nothing in this script listens for that signal yet, so a wrongly-looped clip will only look bad. In Part 02 it will hang the fight.

The debt you just took on#

Look at what drives the fight and what drives the picture. WindUpTimer decides when the slam happens. wind_up decides what the boss looks like while it waits. Those are two independent clocks that happen to be set to the same number right now.

Prove it to yourself. Change wind_up_time to 1.2 in the inspector and run. The boss finishes rearing back and then stands there, pose complete, for half a second of dead air before the slam arrives. The player's read is still correct — the timing is unchanged — but the animation stopped communicating halfway through the wait, and the fight suddenly feels wrong in a way that is hard to name.

Now set it to 0.4. The slam fires while the boss is still in the middle of standing up.

Neither of these is a bug in the code you typed. Both are the consequence of two clocks. Set it back to 0.7 and leave the mismatch in your head. Part 02 replaces the timers with the animation itself, so that there is only one clock and the question cannot come up.

Rhythm is measurable#

_on_strike_finished prints the gap between openings. Play for a minute and read the numbers.

You will see something like 2.4, 2.3, 3.9, 2.3. The outlier is the time you spent running away, which the boss spent walking. Those are the two lengths that define your fight: the floor is wind_up_time + strike_time + recover_time, and everything above it is approach.

Write your numbers down. Not metaphorically — put them in a text file next to the project. Then change one thing and measure again:

  • Halve recover_time to 0.45. The gaps barely move, but the fight tightens hard, because the opening shrank while the cycle did not. Time between openings is not the same measurement as time inside one, and players feel the second far more than the first.
  • Raise strike_range to 220. The boss commits from further away, the approach shortens, and the gaps collapse toward the floor. The fight gets denser without a single number about damage or health changing.

A boss with no silence in it does not have a rhythm, it has pressure. Pressure is a legitimate design goal for one phase of one fight. As the whole fight it is exhausting, and worse, it is unlearnable — a player can only build a mental model of a pattern if there are gaps between the beats to mark where one beat ended.

Pick a target gap and tune toward it deliberately. Something in the region of two to three seconds is a good first fight. Then decide, on purpose, how much of that gap is opening and how much is waiting.

Feedback is not difficulty#

Everything in _flash, _float_text, and the health bar tween is cosmetic in the sense that removing it changes no rule. Remove it anyway and play for thirty seconds.

The fight becomes unlearnable. You swing during recovery, the bar snaps down a hair, and you cannot tell whether the hit landed because you timed it or because the boss happened to be in the right state. You swing during the windup, nothing happens, and you conclude the hitbox is broken. Correct play and incorrect play produce the same silence.

Feedback is not polish applied at the end. Feedback is the mechanism by which a player connects one decision to one result, and without that connection they cannot learn the pattern even when the pattern is completely fair. This is why _guard() exists and why it produces visible output. "Your hit did nothing" and "your hit did nothing because you were early" are the same rule and completely different games.

Three specifics that carry most of the weight:

The flash tween is killed before a new one starts. Two hits in quick succession would otherwise leave two tweens writing to modulate at once, and the loser's final value wins. Store the handle, check is_valid(), kill(). And note the tween comes from create_tween() on the node — a tween made from the SceneTree is unbound and, in the docs' phrasing, may keep working until there is nothing left to animate, which for a freed boss means writing into a hole.

The floating label tweens position:y and modulate:a, not position and modulate. tween_property takes a NodePath, which is what makes sub-property targeting legal, and it is what lets the rise and the fade run as two independent tweeners without fighting over the same value. set_parallel(true) is what makes them run together instead of one after the other — tweeners are sequential by default.

The tween is created after add_child, and never reused. Tweens start the moment you create them, so you cannot build one in _ready() and fire it later, and reusing one is explicitly undefined behaviour. One tween, one animation, made at the moment you want it to run.

The health bar deserves the same reading. It is tweened over 0.25 seconds rather than snapped, which means a hit produces motion the eye tracks rather than a discontinuity it misses. And it only ever moves during recovery, which turns the bar itself into a teaching device: after four or five openings, the player has learned the window without a tutorial prompt, because the bar only rewards one moment.

The health_changed signal is emitted and nothing listens yet. That is intentional. When the boss health bar moves to a HUD at the top of the screen instead of floating over the boss's head, the boss should not have to know that happened — see building the bus for where that goes.

Checkpoint — definition of done#

  • The boss walks toward you, stops around 120 pixels away, and visibly rears back before anything can hurt you.
  • Standing still during the windup and walking away during it both work — the boss commits to the position it picked and does not track you.
  • The slam hitbox is only enabled between the end of the windup and the start of recovery. Turn on Debug → Visible Collision Shapes and confirm it with your eyes, not by inference.
  • Swinging during idle, windup, or strike prints "guarded", flashes grey, and moves the health bar by exactly zero.
  • Swinging during recovery produces a damage number, a white flash, and visible bar movement.
  • The output panel prints a gap between openings, and the number is stable within about a tenth of a second when you stand still and let the boss come to you.
  • Setting wind_up_time to 1.2 makes the boss finish its pose early and stand in dead air — you can see the two clocks disagree.
  • Landing five clean punishes in a row kills the boss, and losing to it leaves you able to name the specific moment you got it wrong.
  • You can explain: why does removing the recovery window change what the player's skill is worth, even though the boss's damage output is identical?

Stretch (no instructions)#

  • Give the boss a second attack with a longer windup and a shorter recovery, chosen at range instead of the slam. Then measure your gaps again and see which attack the player learns first.
  • Make the punish window generous on the first two attacks of the fight and tighten it after that. Decide whether the player should be able to tell.
  • Add a wind-down: play the wind_up clip backwards for the recovery instead of authoring a separate animation. AnimationPlayer.play_backwards() and the from_end argument on play() both exist for exactly this.

If you get stuck#

  • The boss never attacks — it walks into you and stopsstrike_range is smaller than the distance the two bodies come to rest at. The collision shapes are 64 and 32 wide, so the centres cannot get closer than 48 pixels, but a strike_range below that still never triggers. Print the distance and compare.
  • Crash on run: "Cannot call method 'get_node' on a null value" or an invalid playerplayer_path is empty in the inspector. @onready runs get_node at _ready() and there is nothing to resolve. Select the boss, drag the Player node into the field.
  • Nothing ever registers a hit, in either direction → layer and mask are directional. The hurtbox needs layer 3 with mask 2; the hitbox needs layer 2 with no mask. Setting both sides to the same layer is the usual mistake and produces a pair of areas that are perfectly aligned and completely blind.
  • Error in the output about changing a collision shape during a physics callback → a direct disabled = false somewhere. Every enable and disable of a shape in this file goes through set_deferred("disabled", ...).
  • The boss attacks once and then stands still forever → a timer was not made one_shot, or _arm was not called for it, or its timeout was never connected. Also check that nothing calls stop() on a running timer: stop() does not emit timeout, so a beat interrupted that way never runs its handler and the chain ends there.
  • The boss re-slams on top of itself, faster and faster → the opposite failure: a repeating timer. one_shot defaults to false on a fresh Timer node, and each timeout starts another beat while the old one is still running.
  • You take 500 damage in half a second on contacthurt_cooldown is never being set, or _tick_hurt returns before assigning it. The cooldown is the only thing between you and one hit per physics frame.
  • The boss's health bar drains while you stand still, and it dies in about nine seconds without you touching it → the source != self check is missing from the damage loop, or Hitbox.source came back null because the hitbox is not a direct child of its fighter. ContactDamage is larger than Boss/Hurtbox and centred on the same point, so the boss finds its own hitbox on every physics frame. In Stage C the same overlap prints "guarded" forever instead of killing it.
  • The health bar does not move even though damage numbers appear → check Fill is a plain ColorRect under the Node2D, not inside a container. A layout container overwrites size every frame and the tween's writes vanish. Also confirm BAR_WIDTH matches the actual width you gave Back.
  • Damage numbers appear at the top-left corner of the window instead of over the boss → the Label was added to the scene root rather than to the boss, or a Control parent is positioning it. It is added as a child of the boss node so its position is relative to the boss.
  • The boss's rectangle is offset from its collision shapeBody's position is at (0, 0). A ColorRect draws down and right from its position; it needs (-32, -48) to sit centred on a 64 × 96 body.
  • The pose and the timing visibly disagree, and you did not change anything → that is the two-clock problem, working as described. It is not yours to fix in this part.

Next: Part 02 — The anatomy of an attack. Read it once the loop runs and the three beats feel distinct — Part 02 splits the attack into startup, active, and recovery as one owned unit, and pays off the two-clock debt you just took on.