Combat Math and Status Effects

Module 5 · Lesson 3 · ~35 min · Godot 4.x

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.

The damage pipeline

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.

Pipeline golden rule Additive modifiers first. Multiplicative modifiers second. Block last. Int-truncate late. Be consistent.

Status effects as data

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

Balancing: the tuning spreadsheet

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.

The feel tax

Raw math isn't everything. Two things that look like numbers but are actually feel:

  1. Minimum meaningful damage. A word that scores 1 damage feels bad. Either raise the floor (min 2 damage) or never show single-digit numbers (letter values start at 2).
  2. HP granularity. Starting with 30 HP and taking 3-7 damage per hit means ~5-10 turns per duel — sweet spot. Starting with 100 HP feels grindy. Starting with 10 HP is lottery.

Play thirty matches with different starting HP values. Keep a note of what felt right. Perfect game feel is 80% playtesting, 20% spreadsheet.

Do this now

  1. Refactor player/enemy into Combatant nodes with the pipeline above.
  2. Implement StatusEffect, BleedEffect, StrengthEffect as Resources.
  3. Add one card that applies Bleed 2 to the enemy on play. Test a duel. Feel the difference between "big burst word" and "slow bleed stack" strategies.
  4. Move all magic numbers to a tuning.tres Resource. Tweak them between runs without editing code.