The tuning of a card game is a spreadsheet problem disguised as a coding problem. Small number changes cascade: a +1 to starting HP means players take 20% longer to die, which makes long-game strategies viable, which breaks your boss design. This lesson is about the math — and the pipeline for iterating on it.
A single hit isn't damage = amount. It's a pipeline:
damage = word.base_damage
damage *= attacker.strength_multiplier (e.g. Strength +2 → +2 per attack)
damage *= target.weakness_multiplier (e.g. Weakness → -25%)
damage -= target.block (then block is consumed)
damage = max(0, damage)
target.hp -= damage
Implement this as one method. One place to reason about the math.
# scripts/combatant.gd
class_name Combatant
extends Node
@export var max_hp: int = 30
var hp: int
var block: int = 0
var strength: int = 0
var weakness_stacks: int = 0
signal hp_changed(new_hp: int, delta: int)
signal damaged(amount: int)
func _ready() -> void:
hp = max_hp
func take_damage(amount: int) -> void:
var dmg := amount
# Weakness: -25% incoming
if weakness_stacks > 0:
dmg = int(dmg * 0.75)
# Block absorbs first
if block > 0:
var absorbed := min(block, dmg)
block -= absorbed
dmg -= absorbed
if dmg <= 0:
return
hp = max(0, hp - dmg)
damaged.emit(dmg)
hp_changed.emit(hp, -dmg)
func deal_word_damage(target: Combatant, base: int) -> int:
var dmg := base + strength
target.take_damage(dmg)
return dmg
func heal(amount: int) -> void:
var delta := min(amount, max_hp - hp)
hp += delta
hp_changed.emit(hp, delta)
func gain_block(amount: int) -> void:
block += amount
The asymmetry matters: the attacker's Strength is applied before the target's Weakness, which is applied before Block. A 5-damage word with +2 Strength hitting a target with Weakness and 3 Block: (5+2)*0.75 - 3 = 2.25 → 2. If you flip the order, you get different outcomes.
Every status effect is a Resource with hooks into the pipeline:
class_name StatusEffect
extends Resource
@export var duration: int = -1 # -1 = permanent until cleared
@export var stacks: int = 1
# Hooks — subclass overrides the ones it needs
func on_turn_start(_owner: Combatant) -> void: pass
func on_turn_end(_owner: Combatant) -> void: pass
func on_damage_taken(_owner: Combatant, amount: int) -> int: return amount
func on_damage_dealt(_owner: Combatant, amount: int) -> int: return amount
The Combatant holds a list of active effects and iterates them at the right moments:
# In Combatant
var status_effects: Array[StatusEffect] = []
func take_damage(amount: int) -> void:
var dmg := amount
for effect in status_effects:
dmg = effect.on_damage_taken(self, dmg)
# ... block absorption ...
# ... apply ...
func start_turn() -> void:
for effect in status_effects:
effect.on_turn_start(self)
_tick_durations()
func _tick_durations() -> void:
var survivors: Array[StatusEffect] = []
for effect in status_effects:
if effect.duration > 0: effect.duration -= 1
if effect.duration != 0: survivors.append(effect)
status_effects = survivors
Now adding a new status is one Resource class with a couple of overrides. Here's Bleed:
class_name BleedEffect
extends StatusEffect
func on_turn_start(owner: Combatant) -> void:
owner.take_damage(stacks)
stacks = max(0, stacks - 1)
if stacks == 0:
owner.status_effects.erase(self)
Weakness:
class_name WeaknessEffect
extends StatusEffect
func on_damage_dealt(_owner: Combatant, amount: int) -> int:
return int(amount * 0.75)
Strength:
class_name StrengthEffect
extends StatusEffect
func on_damage_dealt(_owner: Combatant, amount: int) -> int:
return amount + stacks
Once the pipeline exists, balance in isolation. Pull the numbers out of code into a tuning Resource:
class_name TuningData
extends Resource
@export var starting_hp: int = 30
@export var starting_hand_size: int = 7
@export var min_word_length: int = 3
@export var length_bonus_exponent: float = 1.5
@export var enemy_hp_scaling: float = 1.15 # per floor
Save as res://data/tuning.tres. All gameplay reads from Global.tuning.starting_hp etc. Tuning a value is: open tuning.tres, change number, save, play. No code changes.
Raw math isn't everything. Two things that look like numbers but are actually feel:
Play thirty matches with different starting HP values. Keep a note of what felt right. Perfect game feel is 80% playtesting, 20% spreadsheet.
Combatant nodes with the pipeline above.StatusEffect, BleedEffect, StrengthEffect as Resources.tuning.tres Resource. Tweak them between runs without editing code.