Lesson 34

Boss Fight

One enemy, three phases, and no attack button. Read the warning, dodge it, then run in and touch the boss while its guard is down.

Loading the game engine. This takes a moment the first time.

Play it first, then read this. Arrow keys move, and there is no attack key at all. You hurt the boss by TOUCHING it, and only while it is gold.

In every other lesson the level holds a world and the enemies are guests in it. Here the boss IS the level: there is nothing else to reach, no exit and no coins, so its brain lives in the level script rather than off in a file of its own.

WHAT TO NOTICE WHILE YOU PLAY

THE TELL
Every attack shows you a pale red shape BEFORE it hurts. That warning is the whole game. Take it away and the fight becomes a coin flip.
THE OPENING
After each attack the boss turns gold and sits still. That is your only way to deal damage, so the fight is a loop: read the tell, dodge, run in, get out.
IT CHANGES
At four health and again at two, the boss speeds up and learns a new attack. Same enemy, new problem, and you have to relearn it mid-fight.
YOU LEARN
Your first death teaches you more than your first minute. Enemy fights are memory tests wearing an action costume.

WHAT AN ATTACK ACTUALLY IS

A shape and a question. _slice_attack() cuts the arena into equal strips and lights some of them up; _near_attack() lights a circle round the boss. Then, on every frame the attack is live, the level asks one question: is the player inside any lit shape? That is the entire combat system, and it is why the tell and the damage can never disagree. They are the same rectangle.

WHY TWO STRIPS ARE NEVER SIDE BY SIDE

The four-strip attacks always leave a safe strip touching every deadly one, so the furthest you ever have to run is half a strip. The warning is short, but it is always long enough. A boss that can kill you before you could physically have moved is not difficult, it is broken.

New code in this lesson

None of the earlier lessons use these. Each one shows the line where it appears here.

enum

A set of named options, like State.WAIT and State.STRIKE. Easier to read than remembering what 0, 1 and 2 meant.

game.gd
enum State { WAIT, TELEGRAPH, STRIKE, RECOVER }

Rect2

A rectangle: a corner and a size. has_point() checks if a spot is inside it.

game.gd
const ARENA := Rect2(40, 210, 1072, 410)

set()

Sets a property by its name in quotes, like node.set("color", RED).

game.gd
(zone["node"] as Node).set("color", STRIKE_COLOR)

create_tween()

Makes a tween: something that slides a value smoothly to a new one over time.

enemy.gd
var tween := create_tween()

set_trans()

Sets the shape of a tween's slide. TRANS_SINE eases in and out instead of moving at one speed.

enemy.gd
tween.set_trans(Tween.TRANS_SINE)

self

This node: the one the script is on.

enemy.gd
tween.tween_property(self, "position:x", new_x, 0.45)

tween_property()

Slides one property of a node, like its position, to a new value over some seconds.

enemy.gd
tween.tween_property(self, "position:x", new_x, 0.45)

get_child_count()

How many nodes sit directly under this one. get_child(0) is the first of them.

hud.gd
if heart_row.get_child_count() != max_hearts:

clamp()

Keeps a number between a low and a high limit.

player.gd
position.x = clampf(position.x, bounds.position.x, bounds.end.x)

The scenes

■ UI node · ■ 2D node · % unique name · .tscn instanced scene

game.tscn
  • Game Node2D game.gd
  • Floor Node2D
  • Zones Node2D
  • Enemy Node2D enemy.tscn
  • Player CharacterBody2D player.tscn
  • HUD CanvasLayer hud.tscn
enemy.tscn
  • Enemy Node2D enemy.gd
  • Body Polygon2D
  • Eye Polygon2D
  • HealthBar Node2D
  • Back ColorRect
  • Fill ColorRect
hud.tscn
  • HUD CanvasLayer hud.gd
  • TopRow HBoxContainer
  • HeartRow HBoxContainer %
  • PhaseLabel Label %
  • MessageLabel Label %
  • Hint Label
player.tscn
  • Player CharacterBody2D player.gd
  • Sprite2D
  • CollisionShape2D

FileSystem

  • game.tscn
  • game.gd
  • enemy.tscn
  • enemy.gd
  • hud.tscn
  • hud.gd
  • player.tscn
  • player.gd

The code

extends Node2D

# The four states, and the boss is in exactly one of them at any moment.
enum State { WAIT, TELEGRAPH, STRIKE, RECOVER }

# The floor the fight happens on. Attacks are cut out of this rectangle,
# and the player is kept inside it.
const ARENA := Rect2(40, 210, 1072, 410)

const BOSS_MAX_HEALTH: int = 6
const PLAYER_MAX_HEARTS: int = 3
## How close you must get to land a hit during an opening.
@export var touch_range: float = 110.0

# Phase 1 timings. Every phase shortens them, see _phase_scale().
const WAIT_SECONDS: float = 0.7
const TELEGRAPH_SECONDS: float = 0.85
const STRIKE_SECONDS: float = 0.35
const RECOVER_SECONDS: float = 1.7

# The warning colour and the it-is-happening-now colour. The SHAPE never
# changes between them, only the paint.
const TELEGRAPH_COLOR := Color(0.92, 0.26, 0.28, 0.2)
const STRIKE_COLOR := Color(0.96, 0.3, 0.3, 0.62)

var state: State = State.WAIT
var state_timer: float = 0.0
var boss_health: int = BOSS_MAX_HEALTH
var fight_over: bool = false

# The shapes that are live right now. Each entry is the maths of one
# attack plus the rectangle or circle the player can see.
var zones: Array[Dictionary] = []

# Which attacks the boss is allowed to use. Phases push more onto it.
var moves: Array[String] = ["half", "near"]
var phase: int = 1

@onready var boss: Enemy = $Enemy
@onready var player: Player = $Player
@onready var zone_layer: Node2D = $Zones
@onready var hud: BossHUD = $HUD

func _ready() -> void:
	_draw_arena()
	# The arena is described once, here, and handed to everything that
	# needs it. The player is kept inside it and the attacks are cut out
	# of it, so the two can never disagree about where the floor is.
	player.bounds = ARENA
	boss.set_health(boss_health, BOSS_MAX_HEALTH)
	hud.set_hearts(player.hearts, PLAYER_MAX_HEARTS)
	hud.set_phase(phase)
	hud.set_message("Dodge the red. Touch the boss while it is gold.")
	_enter_state(State.WAIT)

func _physics_process(delta: float) -> void:
	if fight_over:
		return

	state_timer -= delta

	# While an attack is live, ask the one question, every frame.
	if state == State.STRIKE and _player_in_a_zone():
		_hurt_player()

	# The opening. Touching the boss now is the only damage you can do.
	if state == State.RECOVER:
		if player.position.distance_to(boss.position) <= touch_range:
			_hit_boss()
			return

	if state_timer <= 0.0:
		_advance_state()

# The loop that IS the boss: wait, warn, hit, sit still, wait again.
func _advance_state() -> void:
	match state:
		State.WAIT:
			_begin_attack()
			_enter_state(State.TELEGRAPH)
		State.TELEGRAPH:
			# Same shapes, now solid, now deadly. A ColorRect and a
			# Polygon2D both have a "color", so one line repaints either.
			for zone: Dictionary in zones:
				(zone["node"] as Node).set("color", STRIKE_COLOR)
			_enter_state(State.STRIKE)
		State.STRIKE:
			_clear_zones()
			boss.set_vulnerable(true)
			_enter_state(State.RECOVER)
		State.RECOVER:
			boss.set_vulnerable(false)
			_slide_boss()
			_enter_state(State.WAIT)

func _enter_state(next: State) -> void:
	state = next
	var factor := _phase_scale()
	match next:
		State.WAIT:
			state_timer = WAIT_SECONDS * factor
		State.TELEGRAPH:
			state_timer = TELEGRAPH_SECONDS * factor
		State.STRIKE:
			state_timer = STRIKE_SECONDS
		State.RECOVER:
			state_timer = RECOVER_SECONDS * factor

# Everything except the strike itself gets faster each phase. The strike
# stays put, because shortening it would not make the fight harder, only
# less readable.
func _phase_scale() -> float:
	if phase >= 3:
		return 0.65
	if phase == 2:
		return 0.82
	return 1.0

func _begin_attack() -> void:
	var move: String = moves[randi_range(0, moves.size() - 1)]
	match move:
		"half":
			# Two strips, one of them deadly. The oldest attack in games.
			_slice_attack(true, 2, 1)
		"columns":
			_slice_attack(true, 4, 2)
		"rows":
			_slice_attack(false, 4, 2)
		"near":
			_near_attack()

# Cut the arena into equal strips and light some up. "vertical" decides
# whether the strips stand up or lie down, which is the only difference
# between the columns attack and the rows attack.
func _slice_attack(vertical: bool, count: int, fire: int) -> void:
	var span := ARENA.size.x if vertical else ARENA.size.y
	var width := span / count
	for index: int in _pick_slices(count, fire):
		var rect: Rect2
		if vertical:
			rect = Rect2(ARENA.position.x + index * width, ARENA.position.y, width, ARENA.size.y)
		else:
			rect = Rect2(ARENA.position.x, ARENA.position.y + index * width, ARENA.size.x, width)
		_add_rect_zone(rect)

# A circle centred on the boss. The one attack that pushes you AWAY from
# the thing you need to touch.
func _near_attack() -> void:
	_add_circle_zone(boss.position, 260.0)

# Which strips fire. With two strips it is a coin flip. With four, the two
# that fire are never neighbours, so a safe strip is always next door.
func _pick_slices(count: int, fire: int) -> Array[int]:
	if fire <= 1:
		var single: Array[int] = [randi_range(0, count - 1)]
		return single
	var pairs: Array = [[0, 2], [0, 3], [1, 3]]
	var pair: Array = pairs[randi_range(0, pairs.size() - 1)]
	var picked: Array[int] = [int(pair[0]), int(pair[1])]
	return picked

func _add_rect_zone(rect: Rect2) -> void:
	var view := ColorRect.new()
	view.color = TELEGRAPH_COLOR
	view.position = rect.position
	view.size = rect.size
	zone_layer.add_child(view)
	zones.append({"kind": "rect", "rect": rect, "node": view})

func _add_circle_zone(centre: Vector2, radius: float) -> void:
	var view := Polygon2D.new()
	var points := PackedVector2Array()
	for i: int in 32:
		var angle := TAU * i / 32.0
		points.append(centre + Vector2(cos(angle), sin(angle)) * radius)
	view.polygon = points
	view.color = TELEGRAPH_COLOR
	zone_layer.add_child(view)
	zones.append({"kind": "circle", "centre": centre, "radius": radius, "node": view})

func _clear_zones() -> void:
	for zone: Dictionary in zones:
		(zone["node"] as Node).queue_free()
	zones.clear()

# The one question, asked of every live shape.
func _player_in_a_zone() -> bool:
	for zone: Dictionary in zones:
		if zone["kind"] == "rect":
			if (zone["rect"] as Rect2).has_point(player.position):
				return true
		elif player.position.distance_to(zone["centre"]) <= float(zone["radius"]):
			return true
	return false

func _hurt_player() -> void:
	if not player.take_damage():
		return
	hud.set_hearts(player.hearts, PLAYER_MAX_HEARTS)
	if player.hearts <= 0:
		_finish(false)

func _hit_boss() -> void:
	boss_health -= 1
	boss.set_health(boss_health, BOSS_MAX_HEALTH)
	boss.set_vulnerable(false)
	if boss_health <= 0:
		_finish(true)
		return
	_check_phase()
	# One touch, one hit: the opening closes the instant it lands, so you
	# cannot stand inside the boss and win by leaning on it.
	_slide_boss()
	_enter_state(State.WAIT)

# A phase change is two lines: a new move on the list, and every timer
# shortened. Same boss, new fight.
func _check_phase() -> void:
	if boss_health <= 2 and phase < 3:
		phase = 3
		moves.append("rows")
		hud.set_phase(phase)
		hud.set_message("Phase 3. It is faster, and the floor is not safe either.")
	elif boss_health <= 4 and phase < 2:
		phase = 2
		moves.append("columns")
		hud.set_phase(phase)
		hud.set_message("Phase 2. It learned a new attack.")

func _slide_boss() -> void:
	# The opening turns up somewhere new every time, so you cannot just
	# park where the boss used to be.
	boss.slide_to(randf_range(320.0, 830.0))

func _finish(won: bool) -> void:
	fight_over = true
	_clear_zones()
	player.set_physics_process(false)
	if won:
		hud.set_message("The boss is down. Press Space to fight it again.")
	else:
		hud.set_message("Out of hearts. Press Space to try again.")

func _unhandled_input(event: InputEvent) -> void:
	if fight_over and event.is_action_pressed("jump"):
		get_tree().reload_current_scene()

func _draw_arena() -> void:
	var floor_rect := ColorRect.new()
	floor_rect.color = Color(0.12, 0.13, 0.17)
	floor_rect.position = ARENA.position
	floor_rect.size = ARENA.size
	$Floor.add_child(floor_rect)