Data-Driven Design with Resources

Module 3 ยท Lesson 3 ยท ~40 min ยท Godot 4.x

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.

The principle

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.

If you've built a CMS, this is the same idea. Data is the content. Your systems are the renderer. Game balance is editing content, not code.

Example: the card as data

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.

Why this shape wins

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.

Pattern: Resource with polymorphism

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.

Enemy data, run data, floor 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.

The trap: over-abstracting

Warning Don't build a 30-class hierarchy before writing one line of gameplay. Start with hard-coded values in scripts. When a value changes twice, extract it to a Resource. When you have three variations, extract it to a typed array. Abstraction should be earned by repetition.

A pragmatic rule: rule of three. The third time you copy-paste similar logic, extract the commonality. Before that, it's YAGNI.

Versioning Resource schemas

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:

Do this now

  1. Create the CardEffect base class and a DamageEffect subclass above.
  2. Update CardData to include play_effects: Array[CardEffect].
  3. Open your existing a_tile.tres. You'll see a new "Play Effects" array in the Inspector. Click + โ†’ New DamageEffect. Set amount = 1.
  4. In your Main script, on card tap, iterate 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.