I’m currently learning about architecture and I’m trying to find more optimal options.
My core challenge right now is handling varied behaviors without creating a monolithic script. For example:
Idle Game Context: An upgrade might add a flat bonus (+5 coins/click), a percentage multiplier (x2 production), or a conditional effect (bonus only during certain hours).
Deckbuilder Context: A card might require no target (self-buff), a single target (deal damage), or multiple targets (area of effect).
I need a system where I can easily add a 3rd, 4th, or 100th type of behavior without refactoring the core logic or drowning in match/if statements.
The specific questions I’m wrestling with:
Where does the “logic” live? I tried using resources, but I don’t know where to put the logic, since they’re not part of the scene tree, I can’t reference nodes, although I can write functions that take nodes as arguments.
Inheritance vs. Composition: Should I use subclasses (e.g., FlatUpgrade.gd extends Upgrade.gd), or is there a better way using Godot’s Resource system to swap behaviors dynamically?
I’m looking for a clean, “Godot-native” way to handle this. I’ve looked at the Strategy Pattern and Command Pattern, but I’m unsure how to implement them elegantly with Resources and the Inspector.
Ideally, in the deckbuilder context for example, I could just create a new card resource, and via inspector set its target behaviour along with its effect (healing, damage, poison etc.). How would you go about architecting something like this?
Resources are for data, not for logic.
Composition is the way to go today. UE6 will change to composition with the next big version, too. It’s way easier to parallelize. But I’m looking forward to the suggestions by @dragonforge-dev .
So fun thing, I have a command pattern implementation where I do reference nodes and modify / add / remove nodes from resorces. If you’re interested, I made a post about it not too long ago.
It’s C#, but the same thing applies to GDScript.
You can indeed have logic inside Resources, so the saying that it’s not for logic is false.
Putting logic into resources is actually a good way to approach it for something like a deck builder or any card game with many and varied cards that can have all kinds of effects and modalities, many of them not being designed yet at the time of the initial implementation. Similarly for a clicker with many upgrades of various kinds.
You can implement a base class that defines the interface which encompasses everything a card could possibly do within the system. This base class could be expanded later should the further development of game rules require it.
Then derive each card type from this base class and implement only the parts of the interface relevant for the card.
At play time, the gameplay loop should call all of the card’s interface functions in corresponding contexts. The ones that are not implemented will simply do/yield nothing.
Let me give you an example I’m using in Cataclysm (WIP). It’s not a deck builder but uses a cards mechanic with large number of different cards that have all kinds of effects and modalities.
Base class for cards (a bit simplified here):
class_name BonusCardDef extends Resource
@export var name: String = ""
@export_multiline var description: String = ""
@export var duration: int = 1
@export var image: Resource = null
func _get_play_dice() -> Array[SigilDice]: # sigil dice rolled when the card is played
return []
func _get_turn_dice() -> Array[SigilDice]: # sigil dice rolled at the start of each turn
return []
func _get_damage_bonus(element: int) -> int:
return 0
func _get_totem_type_bonuses(element: int) -> Dictionary: # must return Dictionary[Totem.Type : int]
return {}
func _get_inflict_radius_bonus(element: int) -> float:
return 0.0
func _get_explore_radius_bonus() -> float:
return 0.0
func _get_damage_bonuses_for_terrain(element: int) -> Dictionary: # must return Dictionary[Terrain.Type : int]
return {}
func _get_inflict_radius_bonuses_for_terrain(element: int) -> Dictionary: # must return Dictionary[Terrain.Type : float]
return {}
func _get_explore_radius_bonuses_for_terrain() -> Dictionary: # must return Dictionary[Terrain.Type : float]
return {}
# called when the card is played
func _on_card_played(level: Level, sigil_queue: SigilQueue) -> void:
pass
# called when the card expired
func _on_card_expired(level: Level, sigil_queue: SigilQueue) -> void:
pass
# called when player turn started
func _on_player_turn_started(level: Level, sigil_queue: SigilQueue) -> void:
pass
Note that the last three functions are granted direct access to the game state, passed in via the arguments.
A card can now provide any combination of bonuses allowed by the game rules, as well as act upon the game state in any way it wishes when activated, removed or at each turn it remains active.
Not entirely wrong. They will predominantly contain data in many typical use cases, but there are cases when storing code/logic in them is very convenient.
I think it was very misleading, made it sound like you should never put logic in them or that it’s wrong to do so somehow.
But in certain cases, as shown above, you can build entire designer friendly systems on top, or as normalized showed, it’s great for card games too.
So I think that what you want is very similar to what I laid out in this thread for equipment modifiers. I recommend reading through it as I take you step-by-step through how to build something like this using Resources.
The logic should live with the object. Keeping in mind that a Resourceis an Object. Don’t pass nodes, pass data and get the data you need to function.
class_name Upgrade extends Resource
enum Type {
ADD,
MULTIPLY,
}
@export var name: String
@export var modified_attribute: Attribute.Type
@export var type: Type
@export var modifier: float
func get_modified_value(value: float) -> float:
match type:
Type.ADD:
return value + modifier
Type.MULTIPLY:
return value * modifier
return value
Then you could extend it for one that’s timed.
class_name TimeOfDayUpgrade extends Upgrade
enum TimeOfDay {
DAWN,
MORNING,
AFTERNOON,
EVENING,
NIGHT,
}
## This is the only time of day this effect is active.
@export var time_of_day: TimeOfDay
func get_modified_value(value: float) -> float:
if Global.time_of_day != time_of_day:
return value
else:
super()
Same thing. Create a Resource called EffectArea, and add an Enum and define how many targets are allowed.
Use both. As I illustrated above.
The other day I gave an example using damage modifiers.
I started with one class, DamageModifier. I put a switch statement and used it for Resistance, Absorption, Immunity and Vulnerability. I had an Enum, and a switch statement. Once I had it working, I decided that I wanted those little icons to show up for each one in the editor. That required me to break them out, and simplify the code. Then override the _calculate_amount() function. Once I had all four implemented, I made DamageModifier an @abstract class. This allows me to store whichever one I need using inheritance.
@icon("uid://g3hwd3tvs545")
## Indicates a [Resistance] or [Vulnerability] to [Damage] of a certain
## [enum Damage.Type].
@abstract
class_name DamageModifier extends Resource
## The [enum Damage.Type] of damage to which this [DamageModifier] applies.
@export var type: Damage.Type
## The percentage amount that the [enum Damage.Type] is affected based on the
## [DamageModifier]. [Immunity] ignores this value.
@export var amount: float = 50.0
## Applies the [Damage.Modifier] to the passed [Damage] and returns a modified
## [Damage].
func apply(damage: Damage) -> Damage:
# No modification is done if the type doesn't match.
if damage.type != type:
return damage
var return_value: Damage = Damage.new()
return_value.type = damage.type
return_value.amount = _calculate_amount(damage.amount)
return return_value
## Returns the amount of damage that should be applied to the target after
## being run through this [DamageModifier].
@abstract
func _calculate_amount(damage_amount: float) -> float
@icon("uid://cme8leep3fm24")
## Target is healed by this [enum Damage.Type].
class_name Absorption extends DamageModifier
func _calculate_amount(damage_amount: float) -> float:
return -damage_amount * amount * 0.01
@icon("uid://dg7k58c0riqwa")
## Target is immune to this [enum Damage.Type].
class_name Immunity extends DamageModifier
func _init() -> void:
amount = 100.0
func _calculate_amount(_damage_amount: float) -> float:
return 0.0
@icon("uid://b3d330u53fx84")
## Target takes less damage from this [enum Damage.Type].
class_name Resistance extends DamageModifier
func _calculate_amount(damage_amount: float) -> float:
return damage_amount * amount * 0.01
@icon("uid://c72dh6lk0ihjx")
## Target takes more damage from this [enum Damage.Type].
class_name Vulnerability extends DamageModifier
func _calculate_amount(damage_amount: float) -> float:
return damage_amount + (damage_amount * amount * 0.01)
There’s no real code duplication, but it does create a bunch of tiny files. However when I use them, I get this:
Because I’ve declared DamageModifier an @abstract class, an Array of them can only contain concrete classes implementing it. I also get specific tooltips for each one, and icons that make it easy at a glance to see what type of modifier it is. If I want to save them, I can make it clear what they are and re-use them so I can load a specific one.
If you don’t need the icon granularity in the editor, you may not need to break it down.
Conclusion
You can use inheritance with Resources to create categories of modifiers you want to add to something, and then use Composition by adding Arrays to store an indefinite number of them on a Node.
In my inventory and crafting screens for a game I’m working on now, the UI looks like this (still working on the text appearance):
Resource objects are passed by Reference and RefCounted, so once you create one, there’s just a counter saying how many you have in the game. And all those versions refer to the same place in memory. As your objects get more complex, this means you don’t have to think about memory management. Godot handles it for you.
In parlance for your game, the only things that will be in memory for your game are the modifiers the player needs access to.