Part 04 — The arena and the ending
You will build: A room that takes part in the fight — one attack whose safe answer is a piece of geometry, and a charge that stuns the boss when it hits a wall — plus a death sequence that cannot outlive the boss it animates, and a retry that hands control back in under two seconds.
By the end of Part 03 the boss has phases, and each phase changes what the boss is willing to do. That is the boss half of the fight. This part is the other half: the floor, the walls, the pillars, and what happens in the four seconds after the player dies.
Those two things belong in the same document because they fail together. An arena that does nothing turns every death into "the boss got me somewhere", which is unlearnable. And a retry that takes twelve seconds means the player gets four attempts an hour instead of forty, so nothing gets learned even when the fight is fair.
The test that tells you whether your arena is real#
Do this before writing any arena code.
Make a copy of your fight scene. Delete every pillar, every ledge, every hazard, every piece of terrain that is not the floor and the two side walls. Leave a featureless rectangle wide enough that the boss can still reach the player. Play it.
If the fight plays the same, your arena is wallpaper.
That is not a moral failing, it is a measurement. It means every position in your room is worth exactly the same amount, which means the player's movement carries no decisions — only reaction timing. A fight like that can still be good. Many are. But it is a fight the player learns entirely through their hands, and it will never produce the moment where a player says "I need to be on the left side before he does that."
The rectangle test is worth running again every time you add geometry, because the failure mode is quiet. A pillar that is beautiful and that nobody ever stands behind is wallpaper with a shadow on it.
The cheapest way to make the room participate#
A room participates when at least one attack has a spatial answer instead of a temporal one.
Temporal answer: the boss winds up, you press dodge at the right moment. Where you were standing did not matter.
Spatial answer: the boss winds up, and the only survivable places are behind the pillar, above the floor, or on the far half of the room. Now the player is choosing where to be, and the choice has to be made before the telegraph starts, because there is not enough time to cross the room once it does.
That last clause is the whole design. A spatial answer only creates a decision if the travel time to safety is longer than the telegraph. Otherwise the player reaches the pillar reactively and you have built a temporal answer with extra steps.
The attack below is a ground wave: it travels along the floor, it is too tall to jump, and solid geometry eats it.
The wave#
Give it its own scene: ground_wave.tscn, root Area2D, one CollisionShape2D, one Sprite2D or AnimatedSprite2D.
class_name GroundWave
extends Area2D
@export var speed: float = 260.0
@export var lifetime: float = 4.0
@export var damage: int = 1
var _direction: float = 1.0
var _age: float = 0.0
func _ready() -> void:
area_entered.connect(_on_area_entered)
body_entered.connect(_on_body_entered)
func launch(from: Vector2, direction: float) -> void:
global_position = from
_direction = signf(direction)
if is_zero_approx(_direction):
_direction = 1.0
func _physics_process(delta: float) -> void:
_age += delta
if _age >= lifetime:
queue_free()
return
global_position.x += speed * _direction * delta
func _on_area_entered(area: Area2D) -> void:
# The player's hurtbox is an Area2D, so it arrives through area_entered.
var hurtbox := area as Hurtbox
if hurtbox == null:
return
hurtbox.take_hit(damage, &"ground_wave")
queue_free()
func _on_body_entered(body: Node2D) -> void:
# Solid geometry arrives through body_entered instead. Anything on the
# blocker mask stops the wave — that is the pillar doing its job.
queue_free()
Two things in there are worth more than the rest of the file.
The first is that the wave listens to two different signals for two different kinds of object. area_entered brings the player's hurtbox; body_entered brings the pillar. They are not interchangeable, and a wave that only connects body_entered will pass straight through a player whose hurtbox is an Area2D.
The second is the parameter type on _on_body_entered. It is Node2D, not StaticBody2D. The engine docs are explicit that a body arriving through this signal can be a PhysicsBody2D or a tilemap, and a handler typed to a narrower class throws the first time the wave clips your terrain. If you need to distinguish pillar from wall, filter with collision layers, not with the parameter type.
If your hurtbox script has no class_name, add one now. The cast area as Hurtbox is what lets hurtbox.take_hit(...) compile — in typed GDScript you cannot call a method the declared type does not have, and Area2D does not have take_hit.
Layers, in the one direction that matters#
Area overlap is directional. The overlapping object's layer must appear in the detecting area's mask. Set both on the same side and nothing detects anything.
| Node | Layer (what it is) | Mask (what it looks for) |
|---|---|---|
| Terrain, pillars | World | — |
| Player body | Player | World |
| Player hurtbox (Area2D) | PlayerHurt | — |
| Ground wave (Area2D) | BossAttack | PlayerHurt + World |
The wave has an empty-ish job on its own layer and does all its work through its mask. That is the normal shape for a hitbox: it is the thing that looks, not the thing that is looked at.
Spawning it, and the honest trade-off#
Fire the wave at the end of the slam telegraph you built in Part 02, from a Marker2D at the boss's feet:
func _spawn_ground_wave() -> void:
var wave: GroundWave = ground_wave_scene.instantiate()
get_parent().add_child(wave)
wave.launch(%WaveOrigin.global_position, _facing)
Note get_parent().add_child(wave), not add_child(wave). A wave parented to the boss inherits the boss's transform and rides along with it, and it dies when the boss dies — which sounds tidy until phase 3 and you want the last wave to finish crossing the room after the boss is gone.
Now the fork, and it is a real one.
Make the wave unjumpable, and the pillar is the only answer. Every player learns the same lesson, and the room has exactly one correct read. This is clean and it is also rigid: the attack becomes a rule rather than a decision, and skilled players get no room to express anything.
Make the wave jumpable but punishing — clearable with a well-timed jump, trivially avoided behind the pillar — and you have two answers with different costs. The jump keeps you close and aggressive but demands precision; the pillar is safe but surrenders your position. Now the player is deciding, not obeying.
The deciding question is not which is better. It is: how many other attacks in your fight already have a positional answer? If the ground wave is the only one, make it unjumpable, because you need it to teach the player that position is a resource at all. If three other attacks already push the player around the room, make it jumpable, because the lesson has landed and what the fight needs now is expression.
Let the arena create the punish window#
The room gets more interesting when it hurts the boss, not the player.
Charge attack: the boss commits to a direction, accelerates, and if the player moves out of the lane, the boss hits a wall and is stunned for a beat. The player did not stun the boss. The player arranged for the boss to stun itself, which is a much better feeling and costs you one state.
The hard part is detecting the impact honestly.
velocity is what you asked for; get_real_velocity is what happened#
velocity is an input. You write 420.0 into it and move_and_slide() reads it. It still says 420.0 on the frame the boss is flattened against a wall, because nothing wrote a new value.
get_real_velocity() is an output. It reports the motion that actually occurred after sliding, snapping, and stopping. On the frame the boss hits the wall it collapses toward zero.
The gap between those two numbers is the impact.
The charge is written below as a state node — the BossState base it extends is the one from
state nodes. Rename its members to whatever your own
machine calls them.
extends BossState
## Expects from the base: enter(), exit(), physics_update(delta), a `boss`
## reference, and a `finished` signal that hands control back to the machine.
@export var charge_speed: float = 420.0
@export var min_impact_speed: float = 120.0
var _direction: float = 1.0
var _armed: bool = false
func enter() -> void:
_direction = signf(boss.player.global_position.x - boss.global_position.x)
if is_zero_approx(_direction):
_direction = 1.0
_armed = false
boss.travel(&"charge")
func physics_update(delta: float) -> void:
boss.velocity.x = charge_speed * _direction
boss.velocity.y += boss.gravity * delta
boss.move_and_slide()
# Everything below reads the result of the call above. Reading it before
# move_and_slide() gives you last frame's answer.
var real_speed: float = absf(boss.get_real_velocity().x)
if not _armed:
# Do not let the first frame, before the boss has picked up any speed,
# count as an impact.
_armed = real_speed > min_impact_speed
return
if boss.is_on_wall_only() and real_speed < min_impact_speed:
boss.impact_normal = boss.get_wall_normal()
finished.emit(&"stunned")
func exit() -> void:
boss.velocity.x = 0.0
Three details carry this.
is_on_wall_only() instead of is_on_wall(). In a corner, is_on_floor() and is_on_wall() are both true at once, which is why the _only variants exist. A boss charging into the seam where the floor meets the wall would otherwise trigger the stun on a slope it merely brushed. is_on_wall_only() means the wall is the only thing it is touching.
The order of operations. All of is_on_wall(), is_on_floor(), get_real_velocity(), and get_wall_normal() describe the most recent move_and_slide() call. Query them before you call it and you are reading the previous frame. This is the single most common source of "my collision check is one frame late".
_armed. Without it, frame one reads a real speed of roughly zero — the boss has not moved yet — and the charge stuns itself the instant it begins.
The stunned state that follows is short and does almost nothing: play the stagger animation, zero the horizontal velocity, disable the attack hitboxes deferred, wait, hand control back. Its whole purpose is to exist for long enough that the player can reach the boss and land hits. Time it against your player's actual travel speed from the far edge of the room, not against a number that felt right.
Two body settings that make a heavy boss look wrong#
Both of these bite specifically on large bodies moving through terrain, which is why they show up now and not in Part 01.
func _ready() -> void:
floor_max_angle = deg_to_rad(50.0)
floor_snap_length = 12.0
floor_max_angle is stored in radians. The inspector displays it in degrees, which is the trap: writing floor_max_angle = 45 in code sets a threshold of forty-five radians, and every surface in your game — including vertical walls — starts counting as floor. Your charge attack then never reports is_on_wall_only(), because there are no walls anymore. Always wrap it: deg_to_rad(50.0).
floor_snap_length defaults to 1.0 pixel. That is enough for a small character and nowhere near enough for a boss whose feet are forty pixels apart. Walking down a ramp, the body launches off each lip, spends a frame airborne, and lands — visible as a shimmer, and worse, is_on_floor() flickers false, so any state that checks it stutters. Raise the snap length until the boss stays glued, and do that before you reach for raycasts or custom ground detection.
Hazards obey exactly the same rule as attacks#
A rock that falls out of the ceiling onto a player who had no way to know is not an arena feature. It is a windup problem wearing an arena costume, and it teaches the player that the room is unfair, which is the opposite of what you built the room for.
Every hazard gets its own telegraph, on the same terms as every boss attack: visible, distinct from the other telegraphs, and long enough to answer.
hazard_director.gd, on a node that lives in the arena scene — not on the boss:
class_name HazardDirector
extends Node2D
@export var rock_scene: PackedScene
@export var warning_scene: PackedScene
@export var telegraph_time: float = 0.7
@export var min_gap: float = 5.0
@onready var _boss: Boss = %Boss
@onready var _drop_points: Array[Node] = %DropPoints.get_children()
var _cooldown: float = 3.0
func _physics_process(delta: float) -> void:
if not is_instance_valid(_boss) or _boss.is_dying:
return
_cooldown -= delta
if _cooldown > 0.0:
return
# The arena has its own rhythm, and it has to fit inside the boss's.
# A rock landing during a slam telegraph gives the player two problems
# with one answer window, which reads as noise rather than difficulty.
if _boss.is_committed():
return
_cooldown = min_gap
_drop_rock()
func _drop_rock() -> void:
var point: Marker2D = _drop_points.pick_random() as Marker2D
var warning: Node2D = warning_scene.instantiate()
warning.global_position = point.global_position
add_child(warning)
var t: Tween = create_tween()
t.set_process_mode(Tween.TWEEN_PROCESS_PHYSICS)
t.tween_property(warning, "modulate:a", 1.0, telegraph_time * 0.5).from(0.15)
t.tween_property(warning, "modulate:a", 0.15, telegraph_time * 0.5)
t.tween_callback(warning.queue_free)
t.tween_callback(_spawn_rock.bind(point.global_position))
func _spawn_rock(at: Vector2) -> void:
var rock: Node2D = rock_scene.instantiate()
rock.global_position = at
add_child(rock)
The tween comes from create_tween() on the director, which means it belongs to the arena and dies with the arena. That matters in about ninety seconds, when the boss dies mid-telegraph.
is_committed() lives on the boss and answers "are you already locked into something?":
const ATTACK_STATES: Array[StringName] = [&"wind_up", &"slam", &"charge"]
func is_committed() -> bool:
var playback: AnimationNodeStateMachinePlayback = animation_tree["parameters/playback"]
if not playback.get_travel_path().is_empty():
return true
return playback.get_current_node() in ATTACK_STATES
get_travel_path() is the route the state machine is still working through, so a non-empty path means the boss is on its way into an attack even though the attack has not started. That is exactly the window you want the hazard to stay out of, and asking the animation state machine beats keeping a duplicate is_attacking boolean in GDScript that drifts out of sync.
get_current_node() returns a StringName, not a String. Comparing it against "slam" typed as String silently never matches — hence the &"slam" literals in ATTACK_STATES.
The ending, where the boss stops being a fighter#
Here is the version most people write first, because it is the obvious one:
func die() -> void:
var t: Tween = get_tree().create_tween()
t.tween_property(self, "modulate:a", 0.0, 1.5)
await t.finished
queue_free()
get_tree().reload_current_scene()
It breaks in two directions at once, and you should build it and watch both.
Direction one. A tween from SceneTree.create_tween() is not bound to any node. The docs describe it as capable of running until there is nothing left to animate. Pointed at a boss that queue_free() removed halfway through, that is a crash — or, if you got unlucky with timing, a silent error spam that stops at some arbitrary frame. Node.create_tween() produces a tween that is killed automatically when the node it came from is freed. That one word — get_tree() — is the whole bug.
Direction two. reload_current_scene() restarts everything in the scene. Attempt five opens with the same door, the same camera pan, the same eight-second walk to the arena. Sit through that four times in a row. The irritation you feel on the third repeat is the design lesson: the fight and the ceremony around it are two different objects, and only one of them belongs in a retry.
Shut the boss down deliberately#
Death is a handoff. The boss stops being a thing that acts and becomes a thing that is animated, and the handoff has an order.
signal died(remaining_fraction: float)
var is_dying: bool = false
var _tweens: Array[Tween] = []
var _visual_home: Vector2 = Vector2.ZERO
@onready var visual: Node2D = $Visual
@onready var _attack_shapes: Array[CollisionShape2D] = [
$SlamHitbox/CollisionShape2D as CollisionShape2D,
$ChargeHitbox/CollisionShape2D as CollisionShape2D,
]
func begin_death() -> void:
if is_dying:
return
is_dying = true
# 1. Stop deciding. Nothing should pick a new attack from here on.
state_machine.set_physics_process(false)
phase_controller.set_physics_process(false)
# 2. Stop hurting. Shapes must be changed deferred — the engine blocks a
# direct write mid-physics-step, and this is usually called from one.
for shape: CollisionShape2D in _attack_shapes:
shape.set_deferred("disabled", true)
hurtbox.set_deferred("monitoring", false)
# 3. Stop moving under our own power.
velocity = Vector2.ZERO
# 4. Kill everything still writing to this node's properties. A hit-flash
# tween that outlives the death sequence will fight it for modulate.
_kill_tweens()
died.emit(0.0)
_play_collapse()
func _new_tween() -> Tween:
var t: Tween = create_tween()
t.set_process_mode(Tween.TWEEN_PROCESS_PHYSICS)
_tweens.append(t)
return t
func _kill_tweens() -> void:
for t: Tween in _tweens:
if t.is_valid():
t.kill()
_tweens.clear()
visual is Part 03's _sprite moved under a Node2D named Visual. The collapse rotates, squashes and shakes the picture, and doing any of that to a node the physics step is also writing to means two things fighting over one transform. A wrapper keeps the body's position and the body's portrait separate. If you kept the sprite at the top level, rename this and know what you are trading away.
_attack_shapes lists the shapes this boss actually owns — one line per attack hitbox, so adjust it to match your scene. The as CollisionShape2D on each entry is there because $ hands the type checker back a plain Node; the cast is what tells it these are what the array claims.
Route every tween on the boss through _new_tween() from the start — the hit flash, the telegraph shake, the lunge, all of it. The array is the only way to answer "what is still writing to this node?" at the moment you need to answer it. A tween you created inline three files ago and never stored is a tween you cannot stop.
set_process_mode(Tween.TWEEN_PROCESS_PHYSICS) is in there because a tween defaults to stepping on the render frame while move_and_slide() runs on the physics frame. Any tween that writes to a physics body's position needs to be on the same clock or you get jitter that looks like a physics bug and is not.
Sequencing the collapse#
Two tools, and knowing which is which saves an hour.
Tweeners run one after another by default. parallel() makes only the very next tweener run alongside the previous one. set_parallel(true) flips the default for everything after it. chain() is a barrier: the tweener after it waits for all the previous ones, even in parallel mode.
func _play_collapse() -> void:
_visual_home = visual.position
var t: Tween = _new_tween()
# Shake, tint and squash all at once.
t.set_parallel(true)
t.tween_property(visual, "modulate", Color(1.0, 0.35, 0.35), 0.4)
t.tween_property(visual, "scale", Vector2(1.12, 0.88), 0.4) \
.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
t.tween_method(_shake_visual, 7.0, 0.0, 0.4)
# chain() waits for all three. Then leave parallel mode so the rest of
# the sequence reads top to bottom.
t.chain().tween_interval(0.35)
t.set_parallel(false)
t.tween_callback(_settle_visual)
t.tween_property(visual, "rotation", deg_to_rad(88.0) * _facing, 0.5) \
.set_trans(Tween.TRANS_BOUNCE).set_ease(Tween.EASE_OUT)
t.tween_property(visual, "modulate:a", 0.0, 0.6)
t.tween_callback(queue_free)
func _shake_visual(amount: float) -> void:
visual.position = _visual_home + Vector2(
randf_range(-amount, amount),
randf_range(-amount, amount) * 0.4
)
func _settle_visual() -> void:
visual.position = _visual_home
The pause after the shake is doing more work than the shake. The collapse without it reads as one continuous animation and the player's eye slides off it. With it, the boss staggers, holds, and then falls — and the hold is the beat where the player registers that they won.
One subtlety that will confuse you at some point: a tween_property reads its starting value when that tweener actually begins, not when you wrote the line. In a chained sequence that is almost always what you want, because each step continues from wherever the last one left the property. When it is not what you want — a flash that must always start from full white regardless of what came before — from_current() and from(value) are the two ways to pin it down.
The two create_timer defaults that bite inside a death sequence#
If any part of your sequence uses await get_tree().create_timer(...), read the whole signature:
SceneTreeTimer create_timer(time_sec: float, process_always: bool = true,
process_in_physics: bool = false,
ignore_time_scale: bool = false)
process_always defaults to true, which means the delay keeps counting while the game is paused. A player who opens the pause menu two seconds into a five-second death sequence comes back to a fight that already restarted.
process_in_physics defaults to false, which means the delay resolves on a render frame while the boss moves on a physics frame. For a delay that has to line up with a movement or a hitbox change, that half-frame slop is real.
So for beats inside a death sequence:
await get_tree().create_timer(0.8, false, true).timeout
Pausable, and resolving on the physics frame.
The exception is worth knowing because it inverts both flags. A hitstop — freezing the whole tree for eighty milliseconds on the killing blow — needs the timer to run during the pause it created:
func hitstop(duration: float) -> void:
get_tree().paused = true
await get_tree().create_timer(duration, true).timeout
get_tree().paused = false
With process_always set to false there, the timer never advances, the tree never unpauses, and the game is dead. The rule is not "always pass false". The rule is: ask whether this specific delay should survive a pause, and answer it deliberately.
One more, and it is a timing bug rather than a crash: the countdown starts the moment create_timer is called, not when you await it. Building the timer, doing three hundred milliseconds of setup work, and then awaiting gives you a shorter delay than the number in the call.
What the player is actually owed on a retry#
The fight restarts. The ceremony does not.
Split your arena scene along that line:
Belongs to the room — terrain, pillars, camera framing, the door (already open), the music (already at the fight cue), the hazard director, the HUD.
Belongs to the attempt — the boss instance, the player's health and position, every projectile and wave in flight, every active hazard, the phase counter, the attempt number.
Reset only the second list.
class_name FightRunner
extends Node
@export var boss_scene: PackedScene
@export var restart_delay: float = 0.6
@onready var _boss_slot: Node2D = %BossSlot
@onready var _projectiles: Node2D = %Projectiles
@onready var _player: Player = %Player
@onready var _player_spawn: Marker2D = %PlayerSpawn
@onready var _boss_spawn: Marker2D = %BossSpawn
@onready var _hud: FightHud = %FightHud
@onready var _ceremony: Ceremony = %Ceremony
var _attempt: int = 0
var _death_msec: int = 0
func _ready() -> void:
_player.died.connect(_on_player_died)
await _ceremony.play_intro() # runs exactly once, ever
start_attempt()
func start_attempt() -> void:
_attempt += 1
for shot: Node in _projectiles.get_children():
shot.queue_free()
_player.global_position = _player_spawn.global_position
_player.reset_for_attempt()
var boss: Boss = boss_scene.instantiate()
boss.global_position = _boss_spawn.global_position
_boss_slot.add_child(boss)
boss.died.connect(_on_boss_died)
_hud.show_attempt(_attempt)
if _death_msec > 0:
print("death to control: %d ms" % (Time.get_ticks_msec() - _death_msec))
func _on_player_died(cause: StringName) -> void:
_death_msec = Time.get_ticks_msec()
var boss: Boss = _boss_slot.get_child(0) as Boss
var remaining: float = boss.health_fraction() if is_instance_valid(boss) else 0.0
_hud.show_death(cause, remaining)
await get_tree().create_timer(restart_delay, false, true).timeout
if not is_inside_tree():
return # the player quit to the menu during the delay
_clear_boss()
start_attempt()
func _clear_boss() -> void:
for child: Node in _boss_slot.get_children():
child.queue_free()
_ceremony.play_intro() sits in _ready() and nowhere else. That single placement is what fixes the twelve-second retry, and it is worth noticing that it required no new system — only deciding which node owns what.
The is_inside_tree() guard after the await is not defensive padding. That coroutine resumed on a later frame, and the node it belongs to may have been freed in between. Any await in a node that can be destroyed needs a liveness check on the far side of it, and the same reasoning applies to node references you captured before awaiting — check them with is_instance_valid().
reload_current_scene() is still the right call for "quit and come back". It is the wrong call for "try again", and the difference is not efficiency. It is that the player asked for one more attempt at the fight and you gave them one more viewing of the door.
Then measure it#
Time the gap between the moment the player loses control and the moment they get it back. The print above is enough — you are not building telemetry, you are checking a number once.
Past roughly two seconds, treat it as a bug in the fight rather than a matter of taste. Not because two is a magic number, but because the failure it causes is specific and severe: the player stops running experiments. A fight the player is willing to fail at forty times is a fight they learn. A fight where each failure costs eight seconds is a fight they grind, and grinding does not produce understanding.
Close the loop: tell the player what killed them#
This is the last piece, and it is what the whole shelf has been building toward.
Every hitbox already knows its own name — the &"ground_wave" string the wave passed to take_hit. Carry it through: the hurtbox records the last source, the player emits it on death, the HUD prints it alongside how much boss health was left.
class_name FightHud
extends CanvasLayer
const ATTACK_NAMES: Dictionary = {
&"ground_wave": "Ground Wave",
&"charge": "Charge",
&"slam": "Overhead Slam",
&"falling_rock": "Falling Rock",
}
@onready var _attempt_label: Label = %AttemptLabel
@onready var _death_label: Label = %DeathLabel
func show_attempt(n: int) -> void:
_attempt_label.text = "Attempt %d" % n
_death_label.text = ""
func show_death(cause: StringName, boss_fraction: float) -> void:
var attack_name: String = ATTACK_NAMES.get(cause, "Unknown")
_death_label.text = "%s — boss at %d%%" % [attack_name, roundi(boss_fraction * 100.0)]
Four words on a screen. "Overhead Slam — boss at 34%."
That line converts a death from an event into information. The player now knows which attack they have not solved, and roughly how close the last attempt was, and both of those are reasons to press retry. Without it, the player knows only that they lost, and a loss with no diagnosis feels like the fight cheated even when it did not.
This is the entire difference the shelf opened with. A fight the player can only survive gives them an outcome. A fight the player can learn gives them a reason. Everything in the last four parts — the readable telegraph, the honest hitbox, the phase that changes the question, the room that rewards position, the retry that costs nothing — exists to make sure that after each death there is something specific to do differently.
Checkpoint — definition of done#
- Stripping the arena to a featureless rectangle visibly changes how the fight plays: at least one attack becomes unanswerable without the geometry.
- The ground wave passes through empty space, damages the player on contact, and disappears when it reaches a pillar or the far wall.
- Standing behind the pillar survives a wave that kills you in the open, and the walk to the pillar is longer than the telegraph.
- The boss's charge stuns itself on a wall, and does not trigger on the first frame of the charge or on the floor-wall corner.
- With
floor_max_angleset throughdeg_to_rad(),is_on_wall_only()reports true against a vertical wall and false on a ramp. - The boss walks down a slope without visibly detaching from the ground.
- Every hazard is preceded by a warning the player can see, and no hazard fires while the boss is mid-telegraph.
- Killing the boss during a hit-flash or mid-telegraph produces a clean collapse — no snap-back, no errors in the output panel after it is freed.
- The collapse sequence reads as shake-and-tint together, then a hold, then the fall — not as three separate animations in a row.
- Pausing during the death sequence stops it, and unpausing resumes it where it was.
- On retry, the intro does not replay: the door is open, the camera is already framed on the arena, and only the boss, the player and the projectiles reset.
- The printed death-to-control gap is under two seconds.
- Dying displays which attack killed you and how much boss health was left.
- You can explain: why
get_real_velocity()andvelocitydisagree on the frame the charge hits a wall, and which of the two is the one that knows the truth.
Stretch (no instructions)#
- Make one pillar destructible, so the safe answer to the ground wave is a resource the player spends rather than a place they stand.
- Show the last three causes of death instead of one, and see whether the pattern in that list changes how you tune the fight.
- Give phase 3 a different arena state — a flooded floor, a collapsed pillar — set up by the phase transition rather than by a separate system.
- Skip the collapse animation if the player presses retry during it, without letting the skip path leave a tween running.
If you get stuck#
- The wave passes straight through the player → the player's hurtbox is an
Area2D, so it arrives onarea_entered, notbody_entered. Check both signals are connected, and check that the hurtbox's layer is inside the wave's mask, not the other way round. - The wave dies instantly on spawn → it spawned inside the floor tiles, which are on its blocker mask. Raise the spawn marker, or shrink the wave's shape so it clears the ground.
- The wave is parented to the boss and teleports when the boss moves →
add_childon the boss makes the wave a child of the boss's transform. Useget_parent().add_child(wave)and setglobal_positionafter adding. _on_body_enteredthrows about an invalid type → the parameter is typed narrower thanNode2D. A tilemap can arrive through that signal; widen the type and filter with layers.- The charge never stuns, or stuns immediately → immediately means the
_armedguard is missing and frame one's near-zero real speed counts as an impact. Never means eitherfloor_max_anglewas assigned in degrees so nothing is a wall anymore, or the wall check runs beforemove_and_slide()and is reading last frame. - The charge stuns on the ground →
is_on_wall()instead ofis_on_wall_only(); in the floor-wall corner both flags are set. - The boss shimmers walking down ramps and
is_on_floor()flickers →floor_snap_lengthis still at its 1.0-pixel default. Raise it. - A tweened boss movement and
move_and_slide()visibly fight each other → the tween is on the render clock and the body is on the physics clock.set_process_mode(Tween.TWEEN_PROCESS_PHYSICS). - Errors about a freed instance keep printing after the boss dies → a tween made with
get_tree().create_tween()instead ofcreate_tween(), so nothing killed it when the node went away. Route every tween through the helper and the array. - The boss snaps back to full colour halfway through the collapse → an old hit-flash tween is still writing
modulate. It was created outside the array, so_kill_tweens()never saw it. - The shake, tint and squash play one after another →
parallel()affects only the single next tweener. Useset_parallel(true)for the block. - Everything after the hold plays at once →
chain()only makes the next tweener sequential. Follow it withset_parallel(false). - The death sequence keeps running while paused →
process_alwaysdefaults to true oncreate_timer. Passfalseas the second argument. - The game freezes forever during a hitstop → the opposite mistake:
process_alwayswas set to false on a timer that has to run while the tree is paused. That one needstrue. get_current_node()never matches your state name → it returns aStringName. Compare against&"slam", not"slam".is_committed()is always false → the state machine was never started, soget_travel_path()has nothing in it andtravel()has been silently doing nothing. Wire a transition from the Start node, or callstart()once on the playback.- A crash on retry, referring to a node that exists in the editor → an
awaitresumed after the node was freed. Guard withis_inside_tree()oris_instance_valid()on the far side of every await. - The intro replays on every attempt → the ceremony is being triggered from
start_attempt()rather than from_ready(), or the retry path is still callingreload_current_scene().
Next: Save/Load — 00 Overview. The retry loop you built here lives entirely in memory, which is correct — an attempt should cost nothing. That shelf covers the other half: deciding what a defeated boss should still be true about after the game closes, and where that record has to live so it cannot disagree with itself.