Exercise

Build a Card Hand Layout

Module 2 ยท Exercise 1 ยท ~45 min ยท feeds into capstone

You'll build a UI that spawns a configurable number of cards into an auto-layout container, supports tap-to-select, and adapts to portrait phone resolution. This is the base layer of Lexicon Duel's main battle screen.

Prerequisites

Steps

1Make Card a Control-friendly scene.

Right now your Card extends Node2D. For an HBoxContainer to lay it out, it needs to extend Control. We have two options:

Pick Option A. Re-save card.tscn. The script's extends Node2D needs to become extends Control.

extends Control
class_name Card

signal card_tapped(card: Card)

@export var data: CardData
@onready var letter_label: Label = $Layout/LetterLabel
@onready var value_label: Label = $Layout/ValueLabel

func _ready() -> void:
    custom_minimum_size = Vector2(140, 200)  # minimum tap-friendly size
    if data:
        letter_label.text = data.letter
        value_label.text = str(data.value)

func _gui_input(event: InputEvent) -> void:
    if event is InputEventMouseButton and event.pressed:
        card_tapped.emit(self)
        print("tapped: ", data.letter)

Scene structure inside Card:

Card (Control, custom_minimum_size = 140,200)
โ””โ”€โ”€ Layout (PanelContainer, Full Rect)
    โ””โ”€โ”€ VBox (VBoxContainer)
        โ”œโ”€โ”€ LetterLabel (Label, big font)
        โ””โ”€โ”€ ValueLabel (Label, small font, bottom-right)

2Main scene with a hand container.

Open scenes/main.tscn. Delete the Card instance from Module 1's exercise. Structure:

Main (Node2D)
โ””โ”€โ”€ UI (CanvasLayer)
    โ””โ”€โ”€ Root (Control, Full Rect)
        โ””โ”€โ”€ Margins (MarginContainer, Full Rect, padding 40 all sides)
            โ””โ”€โ”€ HandContainer (HBoxContainer, Bottom Wide, alignment center, separation 16)

Set MarginContainer theme_override constants โ†’ all margins = 40.

Set HandContainer alignment = Center, separation = 16.

3Spawn cards at runtime.

Create scripts/main.gd attached to the Main root. Paste:

extends Node2D

const CARD_SCENE := preload("res://scenes/card.tscn")

# Starting letters โ€” later these'll come from a deck resource.
const STARTING_LETTERS := [
    {"letter": "L", "value": 1},
    {"letter": "E", "value": 1},
    {"letter": "X", "value": 8},
    {"letter": "I", "value": 1},
    {"letter": "C", "value": 3},
    {"letter": "O", "value": 1},
    {"letter": "N", "value": 1},
]

@onready var hand: HBoxContainer = $UI/Root/Margins/HandContainer

func _ready() -> void:
    for entry in STARTING_LETTERS:
        var data := CardData.new()
        data.letter = entry.letter
        data.value = entry.value
        _spawn_card(data)

func _spawn_card(card_data: CardData) -> void:
    var card := CARD_SCENE.instantiate()
    card.data = card_data
    card.card_tapped.connect(_on_card_tapped)
    hand.add_child(card)

func _on_card_tapped(card: Card) -> void:
    print("selected: ", card.data.letter)

4Selection visual.

In scripts/card.gd, add a toggle for selected state:

@onready var layout: PanelContainer = $Layout
var selected := false

func _gui_input(event: InputEvent) -> void:
    if event is InputEventMouseButton and event.pressed:
        toggle_selected()
        card_tapped.emit(self)

func toggle_selected() -> void:
    selected = !selected
    modulate = Color(1.2, 1.2, 0.7) if selected else Color.WHITE

Selected cards tint yellow. Tap again to deselect. Simple feedback.

5Run and resize.

F5. You should see seven cards in a row across the bottom of the screen. Resize the window โ€” the cards stay centered along the bottom. Tap a card โ€” it lights up. Tap again โ€” deselects.

What you built

Stretch goals

If it doesn't work