[Tutorial] Loading a Tarot Card Meanings Dataset in Godot 4

I have been prototyping a data-driven card browser in Godot 4. The main goal was to keep the content outside the scene files, load it once at startup, and select the same card throughout a calendar day.

The example uses the DeckAura tarot card meanings dataset, which contains 78 structured records with fields such as card name, arcana, suit, element, upright meaning, reversed meaning, and guide URL.

The same approach can also be used for item catalogs, dialogue tables, bestiaries, or collectible card games.

Project setup

Save the CSV file as:

res://data/tarot_card_meanings.csv

Create a Control scene with three labels:

CardBrowser
└── VBoxContainer
    ├── CardName
    ├── Metadata
    └── Meaning

Mark the three label nodes as unique names so they can be accessed with %CardName, %Metadata, and %Meaning.

Loading the CSV

extends Control

const DATA_PATH := "res://data/tarot_card_meanings.csv"

@onready var card_name_label: Label = %CardName
@onready var metadata_label: Label = %Metadata
@onready var meaning_label: RichTextLabel = %Meaning

var cards: Array[Dictionary] = []


func _ready() -> void:
    cards = load_cards(DATA_PATH)

    if cards.size() != 78:
        push_error("Expected 78 records, loaded %d." % cards.size())
        return

    show_card(get_daily_card())


func load_cards(path: String) -> Array[Dictionary]:
    var file := FileAccess.open(path, FileAccess.READ)

    if file == null:
        push_error(
            "Could not open %s. Error code: %s"
            % [path, FileAccess.get_open_error()]
        )
        return []

    var headers := file.get_csv_line()
    var result: Array[Dictionary] = []

    while file.get_position() < file.get_length():
        var values := file.get_csv_line()

        if values.size() == 1 and values[0].strip_edges().is_empty():
            continue

        var row: Dictionary = {}
        var column_count := headers.size()

        if values.size() < column_count:
            column_count = values.size()

        for index in range(column_count):
            row[headers[index].strip_edges()] = values[index].strip_edges()

        if row.has("card_name") and not String(row["card_name"]).is_empty():
            result.append(row)

    return result

Using get_csv_line() is important here. Splitting each line with split(",") can break fields containing quoted commas.

Selecting a deterministic daily card

func get_daily_card() -> Dictionary:
    var date := Time.get_date_dict_from_system(false)

    var date_key := "%04d-%02d-%02d" % [
        date["year"],
        date["month"],
        date["day"]
    ]

    var rng := RandomNumberGenerator.new()
    rng.seed = hash(date_key)

    return cards[rng.randi_range(0, cards.size() - 1)]


func show_card(card: Dictionary) -> void:
    card_name_label.text = String(
        card.get("card_name", "Unknown card")
    )

    metadata_label.text = "%s | %s | %s" % [
        card.get("arcana", ""),
        card.get("suit", ""),
        card.get("element", "")
    ]

    meaning_label.text = String(
        card.get("upright_meaning", "")
    )

Because the random number generator is seeded with the local date, reopening the game produces the same selection until the date changes.

One export detail caught me during testing: CSV files may need to be marked as Keep File in the Import dock. Another option is adding *.csv to the non-resource export filter. Otherwise, the source file may not be available through res:// in the exported build.

For a larger game, would you keep the CSV loader at runtime, or convert each row into a custom Resource during the import process?