Still struggling to structure my card game data: Nodes, Resources & Scenes

Godot Version

4.3

Question

Right now I have done two different approaches of structuring my game data (individual cards with custom effects, starter decks, etc.), and I still don’t feel like I have reached a good structure and use everything like it’s intended. The hardest topic for me is how to build a system where card effects are functions that can be overwritten. Another thing is how I can work with this system in the editor without having to create like 200 scenes (for each card type one).

A brief overview:

Approach 1:

  • One general Scene and Node class for the card
  • a general card resource that holds an array of effects, an effect consists of a string defining the type of action and a value defining the specific effect value for that card
  • Whenever a new card is created in the node tree I have to assign a resource to it and whenever the resource changes, all the initial values get copied into the actual node instance
  • A big “Card Resolver” global that has one big resolve function matching the effect type, and executing specific lines of code

The big problem here in my opinion is that all the main logic is centralized in a big global, so whenever I add a new card or want to change an effect, I have to find the specific places in the code that handles the logic, rather than working inside a individual card script file.

Approach 2:

  • One general Scene and Node class for the card
  • a general card resource that holds all initial and runtime data and has all the functions needed for resolving effects (on_draw, on_play() and such)
  • a subclass of the resource class for each type of card that then overwrites the effect functions

The big problem I have here is that Resources in general don’t seem to be build to handle instance data. Numerous times I had bugs caused by resources having a shared instance and a value change on one node caused all other nodes with that resource to change. Even if I manually call set_local_to_scene(true) for each resource, it doesn’t seem to work.
The other problem is that it’s then super hard for me to know if the data I want to change or the function I want to call is inside the node class or the resource class, so I have code like card.card.on_play(), because sometimes I have the Array of nodes, but sometimes I have the array of resources.

Since card games in general are quite a common genre, I’m guessing there must be some “best practices” for those type of games in Godot. Can you guys give me an idea of how you would approach such a game or maybe do you know some good tutorials on that matter?

The second approach I got from this “Slay the spire” copy tutorial (https://www.youtube.com/playlist?list=PL6SABXRSlpH8CD71L7zye311cp9R4JazJ), but again, having everything inside the actual resource seems wrong to me.

3 Likes

I don’t know about best practices, but I’d use both some inheritance, and composition.

Something like a BaseCard class that have all the utils, as you described

class_name  CardBase extends Node2D


@onready var on_draw_effects = $"../on_draw_effects".get_children()
@onready var on_play_effects = $"../on_play_effects".get_children()
@onready var on_death_effects = $"../on_death_effects".get_children()

#######
#EVENTS
func on_draw():
	for effect: Effect in on_draw_effects:
		effect.play()


func on_play():
	for effect: Effect in on_play_effects:
		effect.play()


func on_death():
	for effect: Effect in on_death_effects:
		effect.play()


######
#UTILS
func target_card() -> CardBase:
	#Do something to allow the player to target a card,
	#	then return the target node
	return null


func destroy():
	on_death()
	#bla bla destroy the card

And use nodes to store effects, so that you can create new cards and new effects as scenes

The base effect would be really just a dud for autocomplete

class_name Effect extends Node


@onready var card: CardBase = $"../../CardBase"


func play():
	pass

And then each effect can be its own scene

This way you can compose cards quite fast by just making new scenes, and the code would be carried by the specific effect’s scene

2 Likes

I could see 5 potentially issues with this.

Hard-coded Node Paths, Using Hardcoded node paths like:

@onready var on_draw_effects = $"../on_draw_effects".get_children()

Is gonna create issues if you ever wanna refactor.

Mixing Concerns

The CardBase class is handling both card state/behavior and effect management. This violates the single responsibility principle.

Limited Flexibility for Card Types

Different card types might need very different behaviors, and inheritance alone can become unwieldy as your card system grows more complex. Id try to use inheritance as little as possible with large systems like this.

Missing Signals

Godot’s signal system isn’t being utilized, which would provide better decoupling.

Hard-coded Gameplay Mechanics

Methods like target_card() suggest specific gameplay mechanics built directly into the base class, which might not apply to all card types.

Try something like this instead.

# card_data.gd - Resource for card data
class_name CardData extends Resource

@export var card_name: String
@export var card_image: Texture
@export var description: String
@export var cost: int
# Other card properties...

# effect.gd - Base class for effects
class_name Effect extends Resource

func apply(target, source) -> void:
    pass

# card_base.gd - Base card behavior
class_name CardBase extends Node2D

signal card_drawn
signal card_played(targets)
signal card_destroyed

@export var card_data: CardData
var effects: Array[Effect] = []

func _ready() -> void:
    # Initialize from card_data
    pass

func draw_card() -> void:
    emit_signal("card_drawn")
    
func play(targets = []) -> void:
    emit_signal("card_played", targets)
    
func destroy() -> void:
    emit_signal("card_destroyed")
    queue_free()

# Add effects through code rather than scene hierarchy
func add_effect(effect: Effect) -> void:
    effects.append(effect)

I added some comments in there to show where you can use the improvements and expound on the code a bit. Your code is fine. But you are gonna run into a ton of issues when you want to add literally anything to it ever. You don’t always need this level of expandability. But if you get used to it its just as easy to set up, And its far superior for reusability. For example I use similar class structure for my Inventory system.

2 Likes

Sorry for the long reply. Got in the groove.

1 Like

Don’t be sorry, the more the better! Really helpful stuff!

Your example is pretty close of what I’m trying to achieve. I still see one problem though, that I also have in my project:

With this approach, the effects are separated from the rest of what characterizes a unique card. So for me the cards name, cost, image and effect should all belong to “data”. Now the question is how do I combine this in a good way? In my first approach I said a simple string or enum represents the effect in the data, and the actual effect ist than mapped via code. But this has the problem that all the effect logic needs to be in some kind of huge mapper function.

Ideally I would want to store the effect function as part of the resource, but I don’t really know if that’s possible or how this would work best without subclasses for everything.

You’ve identified a key challenge in card game design: how to store unique effects with card data without creating hundreds of subclasses or a massive switch statement lmao. Give me like 10 and ill type out a quick (4 paragraph) response.

1 Like
  1. My personal favorite. Custom resources with attached scripts:
# card_effect.gd
class_name CardEffect extends Resource

func execute(source, target=null):
    # Override in specific effect scripts
    pass

# card_data.gd
class_name CardData extends Resource

@export var name: String
@export var cost: int
@export var image: Texture
@export var effects: Array[CardEffect]
  1. Effect composition system:
# effect_component.gd
class_name EffectComponent extends Resource

@export var component_type: String  # "damage", "draw", "heal", etc.
@export var value: int = 1
@export var targets: String = "single_enemy"  # "all_enemies", "self", etc.

# card_data.gd
class_name CardData extends Resource

@export var name: String
@export var cost: int
@export var image: Texture
@export var effect_components: Array[EffectComponent]

Then interpret these components with an effect resolver:

# effect_resolver.gd
class_name EffectResolver extends Node

func resolve_effect(card_data: CardData, source, targets):
    for component in card_data.effect_components:
        match component.component_type:
            "damage":
                apply_damage(component, source, targets)
            "heal":
                apply_healing(component, source, targets)
            # etc...
            
func apply_damage(component, source, targets):
    # Apply damage based on component.value and component.targets
    pass

And then my least favorite, 4. Effect inheritance:

# Define effect types through inheritance
class_name DamageEffect extends CardEffect
@export var damage: int = 1
func execute(source, target): target.take_damage(damage)

class_name HealEffect extends CardEffect
@export var heal_amount: int = 1
func execute(source, target): target.heal(heal_amount)

# Then use a factory + string identifiers in card data
class_name EffectFactory extends Node

func create_effect(effect_id: String) -> CardEffect:
    match effect_id:
        "damage_3": 
            var effect = DamageEffect.new()
            effect.damage = 3
            return effect
        "heal_2":
            var effect = HealEffect.new()
            effect.heal_amount = 2
            return effect
    return null

# In card_data, store effect_ids
@export var effect_ids: Array[String]

I recommend the Custom Resources with Attached Scripts approach.

(Btw the code is just AI gen’ed examples. I typed the actual types though.)

Here is a good vid as well

1 Like

Thanks! Yeah I think I can finally wrap my head around the problem. I think approach 1 is also the best but I need to make some key changes in my approach to get this working:

  1. Currently I differentiate cards by a class check (if card is AttackCard). I think I need to put this in a type enum and ditch the classes altogether, as I can overwrite the effect functions via the attached script
  2. When initializing the card node with the resource data, I need to copy everything over to the node instance. Currently I store runtime data inside the resource and call functions directly. Then there will be less confusion on what I currently handle: nodes or resources, since I only work with nodes in the code everywhere, and I also get rid of the nested calls (card.card.modified_value)

I think with these changes I’m on a good way. Thanks again for taking the time and helping me to formulate my vision.

2 Likes

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.