The single biggest productivity multiplier for a solo dev making a content-heavy game is not writing code to describe content. Every enemy, card, ability, floor, and item should be a Resource file you create in the Inspector. Your game logic reads these as typed data. Tweak balance without recompiling. Add 20 enemies without writing 20 classes.
Separate data (what a thing is) from behavior (what the engine does with it). The engine has a small number of systems โ a CombatSystem that applies damage, a HandSystem that manages cards, an AISystem that picks actions. Content is thousands of data files describing specific enemies, cards, effects that these systems consume.
Your first pass had a CardData with letter and value. To get richer, add fields for effects:
# scripts/card_data.gd
class_name CardData
extends Resource
@export var letter: String = "A"
@export var value: int = 1
@export var rarity: int = 0 # 0=common, 1=uncommon, 2=rare
@export_multiline var description: String = ""
@export var art: Texture2D
# Effects โ a typed array of Effect resources
@export var play_effects: Array[CardEffect] = []
# scripts/card_effect.gd โ base class for card-triggered effects
class_name CardEffect
extends Resource
func apply(_context: Dictionary) -> void:
push_warning("CardEffect subclass did not override apply()")
# scripts/effects/damage_effect.gd
class_name DamageEffect
extends CardEffect
@export var amount: int = 1
@export var scales_with_value: bool = true
func apply(context: Dictionary) -> void:
var dmg := amount
if scales_with_value and context.has("card_value"):
dmg += context.card_value
var target: Node = context.target
target.take_damage(dmg)
# scripts/effects/heal_effect.gd
class_name HealEffect
extends CardEffect
@export var amount: int = 3
func apply(context: Dictionary) -> void:
context.player.heal(amount)
Now in the editor, create a CardData .tres, and in its play_effects array add a DamageEffect resource and a HealEffect resource. You just built a composable effect system. Adding a new effect type is a new 5-line script; adding a new card is zero code.
1. Designer ergonomics. You can hire a content designer who never writes GDScript โ they edit .tres files. (You're a solo dev, but this future-proofs the game if you bring on a partner.)
2. Systems get simpler. Your CombatSystem doesn't know about "DamageEffect" or "HealEffect" โ it just calls effect.apply(context). New content doesn't touch the systems.
3. Balance without rebuild. Change a card's damage from 3 to 2 by editing a .tres. Open the game, play. No recompile (GDScript isn't compiled in the traditional sense, but still โ you don't touch logic).
4. Mod-friendly later. If you ever want to ship with modding support, resource files are already the modding format. You'd expose a few more paths and ship.
The trick above โ typed array of a base Resource class โ is the core pattern. Any subclass of CardEffect can be dropped in. In the Inspector, when you hit "Add Element" on play_effects, Godot presents a dropdown of all CardEffect subclasses. This is duck typing at the editor level.
# In game logic
for effect in card_data.play_effects:
effect.apply({"target": enemy, "player": player, "card_value": card_data.value})
That's the entire engine. The for-loop drives every gameplay rule. All content is data.
# scripts/enemy_data.gd
class_name EnemyData
extends Resource
@export var name: String
@export var max_hp: int
@export var sprite: Texture2D
@export var deck: Array[CardData] = []
@export var strategy: EnemyStrategy # another Resource subclass โ AI behavior
# scripts/floor_data.gd โ a node in the roguelike map
class_name FloorData
extends Resource
enum FloorType { BATTLE, SHOP, TREASURE, REST, BOSS }
@export var type: FloorType
@export var enemy_pool: Array[EnemyData] = []
@export var reward_gold: int = 0
# scripts/run_data.gd โ the current run state
class_name RunData
extends Resource
@export var seed: int
@export var current_floor: int = 0
@export var floors: Array[FloorData] = []
@export var player_deck: Array[CardData] = []
@export var player_hp: int = 30
Build this hierarchy up gradually. You don't need all of it in week one. Start with CardData + EnemyData, get the prototype playable, then introduce FloorData when you add the map.
A pragmatic rule: rule of three. The third time you copy-paste similar logic, extract the commonality. Before that, it's YAGNI.
When you add a new field to a Resource, existing .tres files just get the default value for it. Godot handles this gracefully. When you remove or rename a field, existing .tres files quietly lose that data. So:
version: int field on big Resources (like SaveData, RunData).CardEffect base class and a DamageEffect subclass above.CardData to include play_effects: Array[CardEffect].a_tile.tres. You'll see a new "Play Effects" array in the Inspector. Click + โ New DamageEffect. Set amount = 1.card.data.play_effects and call apply({}) โ just print inside DamageEffect.apply for now.You've just built the composable content system you'll use for every card in Lexicon Duel. The exercise next pulls this together into the full game state machine.