Lesson 07

The exit: a gated goal

The win condition. We build an exit that stays locked until every fragment is collected, then activates and ends the game when the player reaches it. This ties the whole loop together.

▸ By the end of this lesson: A locked exit that activates once all fragments are collected and triggers a win on entry

7.1Build the Exit scene

  1. New Scene → "Other Node" → Area3D root, rename Exit. Save as exit.tscn.
  2. Add a child MeshInstance3D → a tall BoxMesh or CylinderMesh (a portal/door pillar).
  3. Add a child CollisionShape3D → a BoxShape3D covering it.

Attach exit.gd. The exit starts inactive and dim; it lights up when unlocked.

exit.gdgdscript
extends Area3D

signal reached()

@onready var mesh: MeshInstance3D = $MeshInstance3D

var active: bool = false

func _ready() -> void:
	body_entered.connect(_on_body_entered)
	_set_visual_locked()

# Called by Main when all fragments are collected.
func activate() -> void:
	active = true
	_set_visual_unlocked()

func _on_body_entered(body: Node3D) -> void:
	if active and body.is_in_group("player"):
		reached.emit()

func _set_visual_locked() -> void:
	_tint(Color(0.3, 0.3, 0.35))    # dull grey = locked

func _set_visual_unlocked() -> void:
	_tint(Color(0.35, 0.9, 0.6))    # bright green = open

# Apply an emissive tint to the exit's material at runtime.
func _tint(c: Color) -> void:
	var mat := StandardMaterial3D.new()
	mat.albedo_color = c
	mat.emission_enabled = true
	mat.emission = c
	mesh.material_override = mat

material_override at runtime

Setting material_override in code swaps the look instantly — no editor material needed. We build a fresh StandardMaterial3D and assign it. This is how you show state changes (locked → unlocked) visually.

7.2Wire the exit into Main

  1. Instance exit.tscn under Main, placed somewhere the player must return to.

Update main.gd: grab the exit, activate it on completion, and win when reached:

main.gd (replace _ready & handlers)gdscript
@onready var exit: Area3D = $Exit

func _ready() -> void:
	var frags: Array = get_tree().get_nodes_in_group("fragments")
	total_fragments = frags.size()
	for frag in frags:
		frag.collected.connect(_on_fragment_collected)
	for hz in get_tree().get_nodes_in_group("hazards"):
		hz.player_hit.connect(_on_player_hit)

	exit.reached.connect(_on_exit_reached)
	_respawn_player()

func _on_fragment_collected(_frag: Area3D) -> void:
	collected_count += 1
	if collected_count >= total_fragments:
		exit.activate()

func _on_exit_reached() -> void:
	print("YOU WIN! All fragments collected and exit reached.")
	# HUD win screen comes in the next lesson.

Guard the activate call

If total_fragments is 0 (you forgot to place fragments or group them), collected_count >= total_fragments is true immediately and the exit activates at once. During testing that's a useful shortcut; before shipping, make sure fragments are actually grouped as fragments.

  1. Run with F6. The exit starts dull grey. Reach it early — nothing happens (it's locked).
  2. Collect every fragment; the exit turns bright green. Walk into it — Output prints the win message.

Checkpoint — expected state