doc 4 of 5

Part 03 — Phases: a state that contains states

You will build: A boss with two phases that share one attack machine. Phase two swaps the attack table and shortens the windups, and the change from one phase to the next is itself a state the boss enters — invulnerable, standing still, and impossible to leave mid-swing.

You finished Part 02 with a boss whose single attack reads. One attack, four beats: it winds up where the player can see it, the hitbox opens for a few frames, and it stands there afterwards long enough to be punished. The fight is legible. It is also boring, because a legible fight with one answer is a fight the player solves once.

The obvious next move is a second phase. That move is a trap, and the trap is worth walking into deliberately, because the bug at the bottom of it is silent.

The machine you have, and why it fit#

The flat enum machine from Part 01 looks like this.

enum State { IDLE, WINDUP, ACTIVE, RECOVER }

var _state: State = State.IDLE
var _state_time: float = 0.0

You typed that enum as Beat { IDLE, WIND_UP, STRIKE, RECOVER }. It is renamed here on purpose: ACTIVE is Part 01's STRIKE, in the frame-data vocabulary Part 02 introduced, because the rest of this part talks about active frames rather than beats. Same four states, same order, same machine — rename yours to match or read ACTIVE as STRIKE throughout.

Four states, one variable, one match in _physics_process. It fit because every question the boss had to answer was the same question: which part of the attack am I in right now? One axis, one variable. That is exactly the shape a flat machine is good at, and reaching for anything heavier at that point would have been architecture for its own sake.

It stops fitting the moment the boss has to answer a second, independent question at the same time. Not "which part of the attack" but "which version of this boss". Two questions, one variable. Watch what that costs.

Add phase two the obvious way#

The requirement is the one every boss gets: at half health the boss speeds up and gains a slam. Write it the way it wants to be written.

func _pick_attack() -> AttackDef:
	if health < max_health * 0.5:
		return phase_two_attacks.pick_random()
	return phase_one_attacks.pick_random()


func _enter_windup(attack: AttackDef) -> void:
	_attack = attack
	var windup: float = attack.windup_time
	if health < max_health * 0.5:
		windup *= 0.7
	_windup_duration = windup
	_animation.play(attack.animation_windup)
	_change_state(State.WINDUP)


func _tick_idle(delta: float) -> void:
	var speed: float = 90.0
	if health < max_health * 0.5:
		speed = 140.0
	velocity.x = signf(target.global_position.x - global_position.x) * speed

Run it. It works. The boss gets meaner at half health, the slam shows up, the windups tighten, the fight has a turn in it. Nothing is wrong on screen, which is the only reason anybody ships this shape.

The cost is not visible yet. It is that health < max_health * 0.5 is now a fact about the boss that lives in three places and belongs to none of them.

Now add phase three#

At a quarter health the boss stops walking and starts teleporting, drops the slam, gains a projectile fan, and the windups tighten again.

Every one of those three if statements becomes a three-branch ladder:

	if health < max_health * 0.25:
		speed = 0.0
	elif health < max_health * 0.5:
		speed = 140.0
	else:
		speed = 90.0

Then the recover duration wants one. Then the telegraph colour. Then the idle animation, because phase three should hover rather than stand. Then the attack cooldown. Seven ladders, and each new phase means editing all seven — not adding to the boss, editing every existing behaviour of the boss.

Then the enum starts asking for help. Phase three hovers, so IDLE is not really one state any more, and somebody writes IDLE_P1 and IDLE_P3. Phase two's slam has a different recovery shape, so RECOVER_SLAM appears. Four states become nine, and nine states have thirty-six possible transitions to reason about instead of twelve.

That multiplication is the symptom, and it has a precise cause: there are two machines here. One answers which phase, one answers which part of the attack, and the flat enum has room for exactly one of them. So the second one gets smeared across every function as a repeated condition.

The reframe: a phase contains the attacks#

A phase is not a fifth state sitting beside WINDUP and ACTIVE. It is a state that contains them.

Outer machine: which phase. Inner machine: which part of the attack. The inner machine is the same four states it always was — the phase changes what those states are made of, not what they are. WINDUP in phase two is still a windup. It reads from a different table.

This nesting is the thing a flat machine structurally cannot express, and it is the same idea a state stack solves from the other direction — see pushing and popping, and the note there about where statecharts came from. The difference: a stack is for states that interrupt and then give control back. A phase never gives control back. It is a lid, not an interruption.

Two resources: what a phase is made of#

The cheapest shape that actually works: the phase owns a table of attacks and a set of tuning numbers. The inner machine reads whatever table is currently live and never asks which phase it is in.

Create res://boss/attack_def.gd:

class_name AttackDef
extends Resource

@export var animation_windup: StringName = &"swipe_windup"
@export var animation_active: StringName = &"swipe_active"
@export var windup_time: float = 0.55
@export var active_time: float = 0.12
@export var recover_time: float = 0.45
@export var damage: int = 1
@export var reach: float = 96.0

And res://boss/boss_phase.gd:

class_name BossPhase
extends Resource

@export var display_name: StringName = &"phase_one"
@export var attacks: Array[AttackDef] = []
@export var enter_at_health_ratio: float = 1.0
@export var move_speed: float = 90.0
@export var attack_cooldown: float = 0.9
@export var windup_scale: float = 1.0
@export var recover_scale: float = 1.0
@export var telegraph_color: Color = Color(1.4, 0.8, 0.8)

enter_at_health_ratio is what turns the threshold from a hardcoded 0.5 into data. Phase one sits at 1.0 because the boss is already in it. Phase two at 0.5, phase three at 0.25.

Make these as .tres files in the FileSystem dock: right-click → New Resource → search for BossPhase. One file per phase, one per attack.

One warning that costs an hour when it bites: resources are shared references. If you duplicate a phase file by selecting the same .tres twice in the array, editing one edits both, and you will swear the phase change is not applying. Right-click the resource in the inspector → Make Unique, or duplicate the file on disk.

The boss reads whatever table is live#

Here is the whole boss script. Type it; do not paste it. The parts that matter are marked in the prose underneath, not in comments.

class_name Boss
extends CharacterBody2D

signal phase_changed(index: int)

enum State { IDLE, WINDUP, ACTIVE, RECOVER, PHASE_SHIFT }

@export var phases: Array[BossPhase] = []
@export var max_health: int = 40
@export var gravity: float = 1200.0
@export var target_path: NodePath

@onready var target: Node2D = get_node_or_null(target_path) as Node2D
@onready var _sprite: Sprite2D = $Sprite2D
@onready var _animation: AnimationPlayer = $AnimationPlayer
@onready var _hitbox: Area2D = $Hitbox
@onready var _hitbox_shape: CollisionShape2D = $Hitbox/CollisionShape2D
@onready var _hitbox_offset_x: float = _hitbox.position.x

var health: int = 0

var _state: State = State.IDLE
var _state_time: float = 0.0
var _phase: BossPhase = null
var _phase_index: int = 0
var _pending_phase: int = -1
var _attack: AttackDef = null
var _already_hit: Dictionary = {}
var _attack_tween: Tween = null
var _invulnerable: bool = false
var _facing: int = 1


func _ready() -> void:
	health = max_health
	_animation.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_PHYSICS
	_hitbox_shape.disabled = true
	_hitbox.body_entered.connect(_on_hitbox_body_entered)
	_apply_phase(0)


func _physics_process(delta: float) -> void:
	if target == null or _phase == null:
		return
	_state_time += delta
	if not is_on_floor():
		velocity.y += gravity * delta
	else:
		velocity.y = 0.0

	match _state:
		State.IDLE:
			_tick_idle()
		State.WINDUP:
			_tick_windup(delta)
		State.ACTIVE:
			_tick_active()
		State.RECOVER:
			_tick_recover()
		State.PHASE_SHIFT:
			velocity.x = 0.0

	move_and_slide()


func _change_state(next: State) -> void:
	_state = next
	_state_time = 0.0


func _tick_idle() -> void:
	_face_target()
	velocity.x = float(_facing) * _phase.move_speed
	if _state_time < _phase.attack_cooldown:
		return
	var choice: AttackDef = _pick_attack()
	if choice != null:
		_enter_windup(choice)


func _pick_attack() -> AttackDef:
	var distance: float = absf(target.global_position.x - global_position.x)
	var in_range: Array[AttackDef] = []
	for attack in _phase.attacks:
		if distance <= attack.reach:
			in_range.append(attack)
	if in_range.is_empty():
		return null
	return in_range.pick_random()


func _enter_windup(attack: AttackDef) -> void:
	_attack = attack
	_face_target()
	velocity.x = 0.0
	_animation.play(attack.animation_windup)
	_start_telegraph(attack)
	_change_state(State.WINDUP)


func _tick_windup(delta: float) -> void:
	velocity.x = move_toward(velocity.x, 0.0, _phase.move_speed * 6.0 * delta)
	if _state_time >= _attack.windup_time * _phase.windup_scale:
		_enter_active()


func _enter_active() -> void:
	_already_hit.clear()
	_animation.play(_attack.animation_active)
	_hitbox_shape.set_deferred(&"disabled", false)
	_change_state(State.ACTIVE)


func _tick_active() -> void:
	velocity.x = 0.0
	if _state_time >= _attack.active_time:
		_close_hitbox()
		_change_state(State.RECOVER)


func _tick_recover() -> void:
	velocity.x = 0.0
	if _state_time >= _attack.recover_time * _phase.recover_scale:
		_attack = null
		_animation.play(&"idle")
		_change_state(State.IDLE)


func _face_target() -> void:
	_facing = 1 if target.global_position.x >= global_position.x else -1
	_sprite.flip_h = _facing < 0
	_hitbox.position.x = absf(_hitbox_offset_x) * float(_facing)


func _start_telegraph(attack: AttackDef) -> void:
	if _attack_tween != null and _attack_tween.is_valid():
		_attack_tween.kill()
	var half: float = attack.windup_time * _phase.windup_scale * 0.5
	_attack_tween = create_tween()
	_attack_tween.set_process_mode(Tween.TWEEN_PROCESS_PHYSICS)
	_attack_tween.set_parallel(true)
	_attack_tween.tween_property(_sprite, "modulate", _phase.telegraph_color, half)
	_attack_tween.tween_property(_sprite, "scale", Vector2(1.08, 0.94), half)
	_attack_tween.chain()
	_attack_tween.tween_property(_sprite, "modulate", Color.WHITE, half)
	_attack_tween.tween_property(_sprite, "scale", Vector2.ONE, half)


func _close_hitbox() -> void:
	_hitbox_shape.set_deferred(&"disabled", true)


func _on_hitbox_body_entered(body: Node2D) -> void:
	if _state != State.ACTIVE or _attack == null:
		return
	if _already_hit.has(body):
		return
	_already_hit[body] = true
	if body.has_method(&"take_damage"):
		body.take_damage(_attack.damage)

Four things in there earn a sentence.

_phase is a reference, not a number. Every tuning read goes through it — _phase.move_speed, _phase.windup_scale, _phase.attacks. There is no if _phase_index == 1 anywhere in the inner machine, and there never will be. That is the whole payoff: adding phase four is a new .tres file dropped into the array, not an edit to seven functions.

target_path and target are two separate variables on purpose. @onready and @export on the same variable is an error in Godot 4 — @onready writes its default after @export has already set the value, so the engine raises ONREADY_WITH_EXPORT rather than let you ship the surprise.

_hitbox_shape.disabled = true in _ready() is a direct assignment, and that is fine, because _ready() is not inside a physics step. Every other place that touches it uses set_deferred. The class reference is explicit that disabled "should be changed with Object.set_deferred()" — the engine will refuse a direct change mid-step, and refuse quietly.

_already_hit is a Dictionary used as a set. Keys are bodies, values are ignored. It clears when an attack starts, which matters in about ninety seconds.

When a table is not enough#

Be honest about the ceiling here. Data-driven phases work when every phase is the same boss with different numbers and different attacks. Walk, pick an attack, wind up, swing, recover.

They stop working when a phase changes the boss's grammar. If phase three hovers instead of walking, has no ground contact, and picks attack positions instead of attack timings, then IDLE in phase three is not the same state as IDLE in phase one with a different move_speed — it is a different state that happens to share a name. Forcing it into the table means move_speed = 0.0 plus a hover flag plus a special case, and the special cases come back.

The deciding question is not how different the numbers are. It is: do the phases share their state list? If yes, one machine and swapped tables. If no, give the divergent phase its own state script and let the outer machine choose between whole machines — the node-per-state shape in state nodes is built for exactly that swap.

Most two-phase and three-phase bosses share their state list. Build the table version first and let the fight tell you when it does not.

The bug that makes the phase change a state#

Now wire the phase change the obvious way, so you can watch it break.

func take_damage(amount: int) -> void:
	health -= amount
	if health < max_health * 0.5 and _phase_index == 0:
		_phase_index = 1
		_phase = phases[1]
		_animation.play(&"phase_shift")

Play the fight. Most of the time it is fine. Then this happens:

The boss commits to a swipe. _enter_active() runs, the hitbox opens, _state is ACTIVE. On the next physics frame the player's own hitbox connects, take_damage() runs from inside the collision signal, health crosses the threshold, _phase gets swapped, and _animation.play(&"phase_shift") throws away the swipe clip.

_tick_active() never gets to run its exit. Actually — worse, and this is the part that hides — it does keep running, because _state is still ACTIVE, so the hitbox eventually closes and the boss looks fine. Now change one thing: let the phase shift also set _state = State.IDLE, which every implementation of this does within a day, because otherwise the boss finishes its old-phase attack during the transformation animation.

Now _tick_active() never runs again. _close_hitbox() never runs. The attack hitbox is still enabled, still monitoring, and it is welded to the boss for the rest of the fight.

The failure the player experiences: they walk up to a boss that is standing perfectly still, doing nothing, and take a hit. Then they back off and walk in again and take another hit, because body_entered fires on the transition into the shape, not once per lifetime — every re-entry is a fresh hit. _already_hit clears at the start of every attack, so even standing still inside the stale box, the next windup re-arms it against them.

No error. No crash. Nothing in the output panel. The boss has an invisible always-on damage aura and the only clue is a health bar that drops for no reason.

That bug is the argument. A phase change is not an instant. It has duration — the animation, the invulnerability, the cleanup — and anything with duration that the boss can be inside is a state. An if cannot be inside anything.

The transition state#

PHASE_SHIFT was already in the enum at the top of the script. Note what it is and is not: it is one new state for the transition, not IDLE_P2 and WINDUP_P2. The outer machine got a state; the inner machine did not multiply.

Add these to the boss.

func take_damage(amount: int) -> void:
	if _invulnerable:
		return
	health = maxi(health - amount, 0)
	if health == 0:
		_die()
		return
	var next_index: int = _phase_index + 1
	if next_index >= phases.size():
		return
	var ratio: float = float(health) / float(max_health)
	if ratio <= phases[next_index].enter_at_health_ratio:
		_begin_phase_shift(next_index)


func _begin_phase_shift(next_index: int) -> void:
	_pending_phase = next_index
	_cancel_current_attack()
	_invulnerable = true
	velocity.x = 0.0
	_animation.play(&"phase_shift")
	if not _animation.animation_finished.is_connected(_on_phase_shift_finished):
		_animation.animation_finished.connect(_on_phase_shift_finished, CONNECT_ONE_SHOT)
	_change_state(State.PHASE_SHIFT)


func _cancel_current_attack() -> void:
	if _attack_tween != null and _attack_tween.is_valid():
		_attack_tween.kill()
	_attack_tween = null
	_close_hitbox()
	_already_hit.clear()
	_attack = null
	_sprite.modulate = Color.WHITE
	_sprite.scale = Vector2.ONE


func _on_phase_shift_finished(anim_name: StringName) -> void:
	if anim_name != &"phase_shift":
		return
	_apply_phase(_pending_phase)
	_invulnerable = false
	_animation.play(&"idle")
	_change_state(State.IDLE)


func _apply_phase(index: int) -> void:
	_phase_index = index
	_phase = phases[index]
	_pending_phase = -1
	phase_changed.emit(_phase_index)


func _die() -> void:
	_cancel_current_attack()
	_invulnerable = true
	set_physics_process(false)
	_animation.play(&"death")

_cancel_current_attack() is the fix, and every line in it is undoing something that would otherwise outlive the phase it belonged to.

The tween gets kill()ed because a tween is not part of the state machine and does not care that the boss has moved on. A telegraph tint that finishes during the transformation leaves the boss the wrong colour for the rest of the fight, and a scale tween mid-flight leaves it visibly squashed. is_valid() guards against a handle whose tween already finished. Note the tween was created with create_tween() on the node, not SceneTree.create_tween() — an unbound tween keeps running against a freed boss, which is the same class of bug one level down.

_close_hitbox() uses set_deferred, and this is precisely where it earns its keep: take_damage() is being called from the player's collision callback, so you are inside the physics step. A direct disabled = true here is the assignment the engine refuses.

_already_hit.clear() and _attack = null exist so the interrupted attack leaves nothing behind for the next one to inherit.

You may also have noticed the guard at the top of _on_hitbox_body_entered: if _state != State.ACTIVE. Keep it — it is cheap, and it turns a stale hitbox from a damage aura into a no-op. But it is a safety net, not the fix. A guard that silently corrects the bug also silently hides it, and the moment you move hitbox control to animation keyframes (below) the guard cannot see what the animation is doing. Fix the state, then keep the net.

Two trade-offs stated plainly, both defensible:

Cancel the attack, or let it finish? The code above cancels — the boss stops mid-swing, transforms, and the player's damage is refunded in tempo. The alternative is to let the swing complete and only then enter PHASE_SHIFT, which reads as a boss too committed to be interrupted. Cancelling is more responsive; finishing is more intimidating. The deciding question is whether your phase transitions are a reward for the player's damage or a punctuation mark in the boss's performance. Either way, the transition is still a state — the difference is only where you call _begin_phase_shift() from.

One phase per threshold crossing. A huge hit that crosses two thresholds at once only advances one phase here, and the next hit advances the second. That is a mercy in most fights and a bug in a few. If you want it to skip, loop the check inside _apply_phase instead of running it once in take_damage.

The phase_changed signal is there so the health bar, the arena, and the music can react without any of them holding a reference to the boss. If you already have a bus, this is the shape it wants — building the bus covers the wiring, and Part 04 uses this signal to change the arena.

Wiring the one-time handler exactly once#

Three separate hazards are stacked in those four lines of connection code, and each one produces a different silent failure.

CONNECT_ONE_SHOT disconnects the handler after a single emission. That is precisely the shape of a phase trigger: one transformation, one callback, no manual disconnect() and no flag to track whether the handler is stale. It fires once and unhooks itself.

But read the flag literally. It disconnects after one emission of the signal — not after one emission that you cared about. animation_finished passes the clip name, so if any other animation finishes first, the one-shot burns on the wrong emission, the anim_name guard bounces it, and the connection is gone. During PHASE_SHIFT the boss plays nothing but the shift clip, so it is safe here. It would not be safe on a signal that fires for reasons you do not control. When in doubt, connect normally and disconnect() in the handler.

Order matters: play() comes before connect(). Wiring the handler after the clip is already playing means there is no window where an outgoing animation could trip it.

A signal connects only once to the same Callable. A second connect() with the same method returns an error. If your setup code runs again on a phase change — which it will, the moment you refactor _apply_phase into something that re-wires nodes — you get an error push and, depending on your error settings, a stopped game. The is_connected() guard is the readable fix. CONNECT_REFERENCE_COUNTED is the other one, and it does something different: it allows the duplicate and requires a matching number of disconnects. Use the guard unless you have a genuine reason to want a counted connection.

The clip must not loop. animation_finished "is not emitted if an animation is looping." A phase_shift clip with looping left on means the handler never runs, _invulnerable never clears, and the boss stands there transforming forever while the player's hits do nothing. Nothing in the output panel will mention it. Check the loop toggle in the Animation panel before you debug a single line of this.

If tying the transition length to a clip makes you nervous, the alternative is a duration in _tick: hold PHASE_SHIFT until _state_time >= shift_duration, then apply the phase. It survives a missing clip and a mis-set loop flag; it drifts out of sync the moment an artist re-times the animation. Signal for fidelity, timer for resilience.

When the chains get long: AnimationTree, and its four sharp edges#

Everything above sequences attacks in GDScript. That holds up while an attack is windup → active → recover. It starts to hurt when phase three wants slam → shockwave → jump-back → landing-slam, with a branch in the middle, because you are now hand-writing a graph in match statements.

That is the point to hand sequencing to an AnimationTree running an AnimationNodeStateMachine. Draw the graph in the editor, transition between clips by name, and let the tree own the timing.

@onready var _tree: AnimationTree = $AnimationTree

var _playback: AnimationNodeStateMachinePlayback = null


func _ready() -> void:
	_tree.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_PHYSICS
	_playback = _tree["parameters/playback"]
	_playback.start(&"idle")


func _travel(state_name: StringName) -> void:
	_playback.travel(state_name)


func _is_committed() -> bool:
	return not _playback.get_travel_path().is_empty()


func _current_animation() -> StringName:
	return _playback.get_current_node()

Four edges, all of which fail without saying anything.

The playback object is fetched by a literal string path. It is not a property and not a method: _tree["parameters/playback"]. Everything on an AnimationTree is addressed through that generic parameter dictionary, including the boolean conditions below. A typo in the path is a runtime problem, not a parse-time one.

travel() on a machine that was never started does nothing and reports nothing. The tutorial says it outright: the state machine must be running before you can travel. Either call start() once as above, or draw a transition from the Start node in the editor. Symptom: your boss stands in its idle pose while your code confidently calls travel(&"slam") every frame.

switch_mode defaults to Immediate, and that quietly deletes your punish window. Select a transition in the AnimationTree editor and look at its properties. Immediate cuts the current clip off wherever it happens to be — so an attack → idle transition truncates the recovery frames, which are the frames you deliberately designed in Part 02 to be the player's opening. The boss becomes unpunishable and nobody can tell you why. Set attack-ending transitions to At End.

advance_mode defaults to Enabled, which means your advance_condition is ignored. Enabled means the transition is only taken by an explicit travel() call. Only Auto consults the condition. Once it is Auto, the condition is a boolean you drive from code at parameters/conditions/<name> — which is how a phase change gets expressed inside the tree:

func _apply_phase(index: int) -> void:
	_phase_index = index
	_phase = phases[index]
	_pending_phase = -1
	_tree["parameters/conditions/phase_two"] = _phase_index >= 1
	phase_changed.emit(_phase_index)

Two more worth knowing before you commit. get_current_node() returns a StringName, so a comparison against a String-typed variable silently never matches — compare against &"slam". And get_travel_path() returns the queued route, which answers "is this boss already committed to an attack?" without you keeping a duplicate flag in GDScript that can disagree with the tree.

One rule with no exceptions: if an AnimationTree is driving the rig, stop calling animation_player.play() on it. The class docs draw the line — the tree owns playback and transitions, the AnimationPlayer owns nothing but the clip library. Two owners writing the same properties produces flicker that looks like a physics bug.

If your hitboxes are opened and closed by Call Method Track keyframes rather than by _tick_active(), know that callback_mode_method defaults to Deferred: the call lands after events are processed, a frame later than the keyframe implies. Switching to Immediate fixes the timing and reintroduces the crash class the default was guarding against — method tracks that free nodes mid-animation. For a hitbox toggle, take the frame of latency and design around it.

The fork#

Two sound answers, stated without a verdict.

Enum machine plus data-driven attack tables — the script in this part. Transition timing lives in your code. You can read the whole fight in one file, step it in the debugger, and unit-test the phase threshold without an animation existing. It gets worse as attack chains get longer, because you are hand-maintaining a graph in text.

AnimationTree state machine plus a thin controller — the graph lives in the editor, your script sets conditions and asks questions. Long chains and branching attacks are drawn rather than written, and an animator can retime the fight without touching GDScript. In exchange, four of your defaults are traps, and a mis-set switch_mode on a transition you did not draw yourself is invisible in code review.

The deciding question: who do you want owning transition timing — your code, or the animation graph? Which reduces to a question about the boss itself. If its attacks are mostly logic — positioning, target selection, projectile spawning, conditional branches on player distance — keep timing in code. If they are mostly animation — long choreographed sequences where the clip is the attack — the graph already contains the truth and duplicating it in a match statement means keeping two versions of the same thing in sync forever.

You can also start in the first and move to the second per-phase. Nothing in the phase resource cares which one is running.

Checkpoint — definition of done#

  • Two BossPhase resources exist as .tres files, are assigned in the inspector, and the phase two file has a different attack list from phase one.
  • The boss enters phase two when its health crosses the ratio stored in the phase two resource, not a number typed in the script.
  • Damage the boss during an active swing so the threshold crosses mid-attack. The transformation plays, and afterwards the player can stand next to a motionless boss indefinitely and take zero damage.
  • During the transformation the boss does not move, does not attack, and takes no damage — hits during the shift do not reduce the health value.
  • The boss's sprite is white and unscaled after a transformation that interrupted a telegraph, not tinted or squashed.
  • Phase two windups are visibly shorter than phase one's for the same attack, driven only by windup_scale on the resource.
  • Add a third BossPhase file, set its ratio to 0.25, drop it in the array. Phase three works with zero changes to the boss script.
  • Triggering a phase change twice in one fight produces no "signal is already connected" error in the output panel.
  • Search the boss script for _phase_index == and find no matches outside _apply_phase and the AnimationTree condition line.
  • You can explain: why the phase change needed its own state rather than an if-check, and name the specific thing that gets abandoned mid-swing when it does not have one.

Stretch (no instructions)#

  • Make the transformation cost the player something — a shockwave that clears the arena floor, spawned from the shift clip rather than from _begin_phase_shift().
  • Give one phase a scripted opening attack: the first attack after a shift is always the same one, and the table takes over afterwards.
  • Let a phase override the idle behaviour without a flag — a Script exported on BossPhase that the boss instantiates and calls, so phase three can hover without phase one knowing hovering exists.
  • Add a second trigger for the same transition state: a phase that begins on a timer instead of a health threshold, entering the same PHASE_SHIFT state through a different door.

If you get stuck#

  • Boss freezes in the transformation pose forever, no error → the phase_shift clip has looping enabled. animation_finished is never emitted for a looping animation, so _on_phase_shift_finished never runs and _invulnerable never clears.
  • Phase never advances, but the transformation animation plays → the anim_name guard is comparing against a clip name that does not match the one you played, or _pending_phase is still -1 because _begin_phase_shift was called from somewhere that skipped it.
  • "Signal is already connected" in the output on the second phase change → the is_connected() guard is missing, or you moved the connection into _apply_phase where it runs every phase.
  • Player takes damage from a motionless boss_close_hitbox() is not being reached on the interrupt path, or it is using a direct disabled = true instead of set_deferred. Add a temporary print of _hitbox_shape.disabled in _physics_process and watch it during a mid-swing phase change.
  • Player takes one hit from the stale box and never again → the hitbox is still open but _already_hit still holds them. Same root cause; the dedupe set is masking half the symptom.
  • Both phases have identical windup timings_tick_windup is reading _attack.windup_time without multiplying by _phase.windup_scale, or both array slots point at the same .tres file. Check the second one first: resources are shared references, so editing "one" edits both.
  • Phase two's numbers apply but its attacks do not → the attacks array on the phase two resource is empty or holds the same AttackDef files as phase one. _pick_attack() returning null leaves the boss walking forever, which looks like a broken state machine and is a broken resource.
  • Boss jumps straight to the last phase from one hit → the threshold check is running before _invulnerable = true is set, so a second hit lands in the same frame. Confirm _begin_phase_shift sets it before anything can call take_damage again.
  • Sprite stays tinted or squashed after a transformation → the telegraph tween was killed but its properties were never restored. kill() stops a tween where it stands; it does not rewind it.
  • Tween-driven telegraph stutters against the boss's movement → the tween is on the render clock. set_process_mode(Tween.TWEEN_PROCESS_PHYSICS) on anything that writes to a node the physics step also moves.
  • (AnimationTree) travel() does nothing, no error → the state machine was never started. Call start() once or wire a transition from the Start node.
  • (AnimationTree) attacks cut straight to idle with no recovery framesswitch_mode on that transition is still Immediate. Set it to At End.
  • (AnimationTree) an advance_condition never fires by itselfadvance_mode is still Enabled. Only Auto reads the condition.
  • (AnimationTree) a comparison against get_current_node() is never true → it returns a StringName. Compare against &"slam", not "slam" held in a String-typed variable.

Next: Part 04 — The arena and the ending. Read it once your phases hold up under a full fight, because the arena is what makes a phase change readable from across the screen — and because a boss that never dies properly is the last silent bug in this shelf.