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?