Lesson 11

The HUD: score, level, and a restart screen

Numbers in the Output panel aren't a game. We add a proper on-screen HUD with score, level, and lines using Control nodes, plus a Game Over panel with a Restart button.

▸ By the end of this lesson: A live HUD and a working Restart button, all wired to the game state

11.1Control nodes live on a CanvasLayer

UI should ignore the game camera and always sit on top. That's what a CanvasLayer is for. Build the HUD as children of one.

  1. Open main.tscn. Right-click Main → Add Child → CanvasLayer, rename HUD.
  2. Under HUD, add a Label. Rename it ScoreLabel. In the Inspector set its position to the right of the well (e.g. x=340, y=20) and type a placeholder like Score: 0.
  3. Duplicate it (Ctrl/Cmd+D) twice to make LevelLabel and LinesLabel; stack them below (y=60, y=100).

Node paths for UI

From main.gd you reach these with $HUD/ScoreLabel. Because the HUD is a child of Main, the path is stable. If you nest deeper, update the path accordingly.

11.2Push state into the labels

Cache the labels and add an update method to main.gd:

main.gdgdscript
@onready var score_label: Label = $HUD/ScoreLabel
@onready var level_label: Label = $HUD/LevelLabel
@onready var lines_label: Label = $HUD/LinesLabel

func _refresh_hud() -> void:
	score_label.text = "Score: %d" % score
	level_label.text = "Level: %d" % level
	lines_label.text = "Lines: %d" % lines_cleared_total

Call _refresh_hud() at the end of _ready() and inside _award_score() so it updates on every clear:

main.gd (replace _award_score)gdscript
func _award_score(cleared: int) -> void:
	cleared = clampi(cleared, 0, 4)
	score += LINE_SCORES[cleared]
	lines_cleared_total += cleared
	_update_level()
	_refresh_hud()

11.3A Game Over panel with Restart

  1. Under HUD, add a PanelContainer, rename GameOverPanel. Center it over the well.
  2. Inside it add a VBoxContainer, and inside that a Label (text: Game Over) and a Button (text: Restart, name it RestartButton).
  3. Select GameOverPanel and turn its Visible property OFF in the Inspector — it should be hidden until you lose.

Show the panel on game over and wire the button. In main.gd:

main.gd (replace _ready, add handler)gdscript
@onready var game_over_panel: PanelContainer = $HUD/GameOverPanel
@onready var restart_button: Button = $HUD/GameOverPanel/VBoxContainer/RestartButton

func _ready() -> void:
	board = Board.new(COLS, ROWS)
	_draw_border()
	game_over_panel.visible = false
	restart_button.pressed.connect(_on_restart_pressed)
	_refresh_hud()
	spawn_piece()

func _on_restart_pressed() -> void:
	get_tree().reload_current_scene()

Reveal the panel in _trigger_game_over():

main.gd (replace)gdscript
func _trigger_game_over() -> void:
	game_over = true
	active_piece = null
	game_over_panel.visible = true

Signals: pressed.connect

Buttons emit a pressed signal. restart_button.pressed.connect(_on_restart_pressed) runs our function when clicked. reload_current_scene() is the simplest possible restart — it rebuilds the whole scene fresh.

Match the node path exactly

The path $HUD/GameOverPanel/VBoxContainer/RestartButton must match your tree names exactly, including capitalization. A wrong path gives a null and a crash on the @onready line. Rename carefully or copy the path with right-click → Copy Node Path.

  1. Run with F5. The HUD shows live score, level, and lines.
  2. Lose on purpose — the Game Over panel appears. Click Restart and the game resets clean.

Checkpoint — expected state