# Spire Combat System — Design Spec > Describes how individual spire rooms are fought: weapons, spell autocasting, > mana costs, damage calculation, elemental matchups, armor, shields, barriers, > enemy modifiers, debuffs/DoT, golems, and the combat tick pipeline. --- ## 1. Objective Spire combat is the micro-game fought in every combat room. The player does **not** manually trigger attacks — all weapons and golems fight automatically on their own timers. Early game this means one staff autocasting one spell; late game it can mean multiple weapons each on their own cast timer, plus golems attacking in parallel. **Design goals:** - Combat is fully automatic once a room is entered. No input required. - Damage math is transparent and multiplicative: base × discipline × boon × element × crit. - Enemies have meaningful defensive variety via modifiers (armored, mage, shield, agile, swarm). - Guardian bosses have an additional layer of defense (shield pool, percentage barrier, health regen). - The player is **immortal** — no player HP, no armor, no healing, no lifesteal. - Room clearing is determined by total enemy HP reaching 0, which triggers advancement. --- ## 2. Combat Sources There are three independent sources of damage, each running on its own timer: | Source | Mana Cost | Attack Speed | Damage | Notes | |---|---|---|---|---| | **Staff / spells** | Yes — per cast | Determined by spell's `castSpeed` | Moderate–high; scales with enchantments | Can apply debuffs/DoT/special effects | | **Sword / melee** | None | Determined by weapon's `attackSpeed` stat | Lower than spells; fast | Elemental damage via enchantment; no mana drain | | **Golems** | Maintenance cost per tick (not per attack) | Per-golem `attackSpeed` | Variable by golem tier | See §6 | ### 2.1 Player Does Not Choose Spells The player **does not select which spell to cast**. All spells granted by equipped weapons are autocast simultaneously, each on its own independent cast timer. - **Early game:** One staff with one spell → one autocast timer. - **Late game:** Multiple weapons with multiple spells → multiple independent timers, all firing in parallel. - The late-game ability to manually prioritise or pin specific spells is a prestige/ discipline unlock and is **out of scope for the initial implementation**. ### 2.2 Staves (Spell Weapons) - Grant spells via `effect.type === 'spell'` enchantments. - Each equipped staff can carry one or more spell enchantments. - Each spell on a staff runs its own `castProgress` accumulator. - Casting a spell costs mana (raw or elemental, per the spell's `cost` definition). - If the player cannot afford a spell's cost, that spell's cast is held (progress does not reset) until mana is available. ### 2.3 Swords (Melee Weapons) - Deal physical + optional elemental damage via `effect.type === 'bonus'` enchantments (e.g. `fireAttack`, `waterAttack` enchant types). - Cost **no mana** per swing. - Faster attack speed than spells but lower damage per hit. - Use the **same elemental matchup table** as spells (1.25× resonance, 1.5× super effective, 0.75× weak — see §4.2). - Sword auto-attacks run on their own `meleeProgress` accumulator, independent of spells. --- ## 3. Combat Tick Pipeline ### 3.1 Tick Overview (every 200ms / `HOURS_PER_TICK = 0.04`) ``` gameStore.tick() └─ if currentAction === 'climb': └─ processCombatTick(combatStore, ...) ├─ for each equipped spell (each on own castProgress): │ ├─ castProgress += HOURS_PER_TICK × spell.castSpeed × attackSpeedMult │ └─ while castProgress >= 1 AND canAffordCost: │ ├─ deductSpellCost() │ ├─ calcDamage() → apply elemental + crit │ ├─ onDamageDealt(dmg) → specials + enemy defenses │ ├─ applySpellEffects() → debuffs / DoT (§5) │ └─ applyDamageToRoom(finalDmg) │ ├─ for each equipped sword (each on own meleeProgress): │ ├─ meleeProgress += HOURS_PER_TICK × sword.attackSpeed │ └─ while meleeProgress >= 1: │ ├─ calcMeleeDamage() → elemental matchup applied │ ├─ onDamageDealt(dmg) → enemy defenses (no specials for melee) │ └─ applyDamageToRoom(finalDmg) │ ├─ for each active golem (§6): │ ├─ golemProgress += HOURS_PER_TICK × golem.attackSpeed │ ├─ check maintenance cost (deduct or dismiss golem) │ └─ while golemProgress >= 1: │ ├─ calcGolemDamage() │ ├─ applyGolemEffects() → per-golem special effects │ └─ applyDamageToRoom(finalDmg) │ ├─ tick active DoT/debuff effects on enemies (§5.3) │ └─ if allEnemyHP <= 0: onRoomCleared() → advanceRoomOrFloor() ``` ### 3.2 `applyDamageToRoom` ``` applyDamageToRoom(dmg, targetEnemy?): if spell is AoE and targetEnemy is null: // distribute damage across all enemies for each enemy in room: enemy.hp = max(0, enemy.hp - dmg) else: target = targetEnemy ?? lowestHPEnemy() target.hp = max(0, target.hp - dmg) if all enemies.hp === 0: onRoomCleared() ``` > **Targeting:** Non-AoE attacks target the enemy with the lowest current HP by > default (focus-fire to clear rooms faster). This is implicit — no UI selection. --- ## 4. Damage Calculation ### 4.1 Spell Damage (`calcDamage` in `combat-utils.ts`) ``` baseDmg = spell.baseDamage + disciplineEffects.baseDamageBonus pct = 1 + disciplineEffects.baseDamageMultiplier rawMult = 1 + boons.rawDamage / 100 elemMult = 1 + boons.elementalDamage / 100 critChance = boons.critChance / 100 critMult = 1.5 + boons.critDamage / 100 damage = baseDmg × pct × rawMult × elemMult if spell.elem !== 'raw': damage ×= getElementalBonus(spell.elem, enemy.element) if Math.random() < critChance: damage ×= critMult ``` ### 4.2 Elemental Matchup (`getElementalBonus`) Used by both spells and swords. | Relationship | Multiplier | |---|---| | Spell/sword element === enemy element | 1.25× (resonance) | | Spell/sword element is the **counter** of enemy element | 1.5× (super effective) | | Enemy element is the **counter** of spell/sword element | 0.75× (weak) | | Raw element (no element) | 1.0× (neutral) | | All other combinations | 1.0× (neutral) | Elemental counters (partial list): ``` fire ↔ water air ↔ earth light ↔ dark frost ↔ fire lightning → water earth → lightning ``` Composite element counters: ``` blackflame counters: frost, water, light (frost/water/light also counter blackflame) radiantflames counters: frost, water, dark (frost/water/dark also counter radiantflames) ``` > All 22 mana types (base, utility, composite, exotic) are valid spell elements. > Composite/exotic elements use the same matchup table; multi-element spells use > `getMultiElementBonus()` which applies `Math.min()` across all enemy element matchups, > making it harder to exploit a single counter-element. **Multi-element guardians:** `getMultiElementBonus()` uses `Math.min()` across all guardian elements, making it harder to exploit a single counter-element. ### 4.3 Melee Damage (`calcMeleeDamage`) ``` baseDmg = sword.baseDamage + sword.elementalEnchantDamage damage = baseDmg × getElementalBonus(sword.enchantElement, enemy.element) // No critChance, no discipline damage bonus for melee in v1 // attackSpeedMult from equipment does apply to meleeProgress accumulation ``` ### 4.4 Discipline Combat Specials Applied inside `onDamageDealt` before enemy defenses: | Special | Condition | Effect | |---|---|---| | **Executioner** | Enemy HP < 25% of maxHP | `dmg × = 2` | | **Berserker** | Player rawMana < 50% of maxMana | `dmg × = 1.5` | Both can apply simultaneously (stack multiplicatively). Melee attacks do **not** trigger Executioner or Berserker in v1. ### 4.5 Speed Room + Agile Modifier Interaction When a room is of type `speed` **and** the enemy also has the `agile` modifier, the effective dodge chance is computed additively: ``` effectiveDodge = speedRoomBonus + agileDodgeChance // e.g. speedRoom adds +0.20, agile adds up to 0.55 → cap at 0.75 effectiveDodge = min(0.75, speedRoomBonus + agileDodgeChance) ``` `speedRoomBonus` is a constant (suggested: `0.20`). This ensures speed rooms remain meaningfully harder than plain combat rooms even without an agile modifier. --- ## 5. Enemy Defenses ### 5.1 Enemy Modifiers Each enemy can have up to **2 modifiers** (randomly selected, floored-gated): | Modifier | Min Floor | Max Chance | Stat Effect | |---|---|---|---| | `armored` | 5 | 40% | `armor = min(0.45, floor × 0.003)` — % damage reduction | | `shield` | 10 | 25% | One-time barrier pool = 15% of maxHP | | `agile` | 12 | 25% | `dodgeChance = min(0.55, floor × 0.003)` | | `mage` | 15 | 30% | `barrier = min(0.4, floor × 0.003)`; recharges 5%/tick | | `swarm` | 8 | 15% | Spawns 3–7 enemies at 35% HP each | ### 5.2 Damage Reduction Order (Regular Enemies) ``` onDamageDealt(dmg, enemy): // 1. Dodge check if enemy.dodgeChance > 0 && Math.random() < enemy.dodgeChance: activityLog("Attack dodged!") return 0 // 2. Barrier absorption (percentage) if enemy.barrier > 0: dmg ×= (1 - enemy.barrier) // Mage barrier recharges: enemy.barrier = min(barrierMax, enemy.barrier + rechargeRate) // 3. Armor reduction (flat percentage) if enemy.armor > 0: dmg ×= (1 - enemy.armor) return dmg ``` > **Note:** In the current codebase, armor, barrier, and dodge for regular enemies > are stored on `EnemyState` but **not yet applied** in the pipeline. This spec defines > the intended implementation. See §9 for full gap list. ### 5.3 Guardian Defensive Pipeline Applied inside `makeOnDamageDealt` in `combat-tick.ts` (already partially implemented): ``` onDamageDealt(dmg) [guardian room]: // Specials first (Executioner, Berserker) dmg = applyDisciplineSpecials(dmg) // Regen ticks guardianShield = min(shieldMax, guardianShield + shieldRegen × HOURS_PER_TICK) guardianBarrier = min(barrierMax, guardianBarrier + barrierRegen × HOURS_PER_TICK) // Shield absorption (flat pool first) absorb = min(guardianShield, dmg) guardianShield -= absorb dmg -= absorb // Barrier reduction (percentage) if guardianBarrier > 0: dmg ×= (1 - guardianBarrier) // Health regen (reduces net damage) healAmount = healthRegenIsPercent ? floor(floorMaxHP × healthRegen / 100 × HOURS_PER_TICK) : floor(healthRegen × HOURS_PER_TICK) dmg -= healAmount // can go negative, effectively healing floorHP return dmg ``` --- ## 6. Debuffs and Damage-Over-Time ### 6.1 Overview Some spells and golem attacks apply effects that persist on enemies between ticks. These are tracked in `EnemyState.activeEffects: ActiveEffect[]`. ```typescript interface ActiveEffect { type: EffectType; remainingDuration: number; // in ticks magnitude: number; // effect strength (damage per tick, % reduction, etc.) source: 'spell' | 'golem'; bypassArmor?: boolean; bypassBarrier?: boolean; } type EffectType = | 'burn' // fire DoT per tick | 'poison' // nature DoT per tick, stacks | 'bleed' // physical DoT per tick | 'freeze' // slows enemy (future: reduces attack speed of enemy, if relevant) | 'slow' // reduces enemy barrier/dodge temporarily | 'curse' // amplifies incoming damage by % | 'armor_corrode' // reduces armor value by % for duration | 'blind' // increases dodge miss rate on enemy attacks (N/A — player immortal; repurpose as accuracy debuff) ``` ### 6.2 Applying Effects Spells that apply effects include the effect definition in their `SpellDefinition`: ```typescript interface SpellDefinition { // ...existing fields... onHitEffect?: { type: EffectType; duration: number; // ticks magnitude: number; bypassArmor?: boolean; bypassBarrier?: boolean; applyChance?: number; // 0-1, defaults to 1.0 }; } ``` On a successful hit: ``` if spell.onHitEffect && Math.random() < (spell.onHitEffect.applyChance ?? 1.0): enemy.activeEffects.push({ ...spell.onHitEffect, remainingDuration: spell.onHitEffect.duration }) activityLog("${enemy.name} afflicted with ${effectType}") ``` ### 6.3 Effect Tick Processing Each combat tick, after all weapon attacks, active effects are processed: ``` tickActiveEffects(enemy): for each effect in enemy.activeEffects: if effect is DoT (burn/poison/bleed): dmg = effect.magnitude if effect.bypassArmor: // skip armor reduction step dmg applied directly to enemy.hp elif effect.bypassBarrier: dmg applied after armor, before barrier else: dmg = applyEnemyDefenses(dmg, enemy) enemy.hp = max(0, enemy.hp - dmg) elif effect is 'curse': // Tracked on enemy; checked in calcDamage to amplify incoming damage incomingDamageMult × = (1 + effect.magnitude) elif effect is 'armor_corrode': // Temporarily reduce armor enemy.effectiveArmor = max(0, enemy.armor - effect.magnitude) effect.remainingDuration -= 1 if effect.remainingDuration <= 0: remove effect from enemy.activeEffects ``` ### 6.4 Spell Effect Examples | Spell type | Effect | Notes | |---|---|---| | Fire spells | `burn` — fire DoT, 3–5 ticks | Standard DoT | | Death spells | `curse` — +20% incoming damage for 4 ticks | Amplifier (no "nature" element) | | Lightning spells | `armor_corrode` — -15% armor for 3 ticks | Bypass synergy | | Frost spells | `freeze` / `slow` — reduces effective dodge | Soft CC (note: "frost", not "ice") | | Void/shadow spells | `bypassArmor: true` | Direct to HP | | Certain advanced spells | `bypassBarrier: true` | Ignores shield/barrier | --- ## 7. Spell Autocasting — Late Game Manual Override The initial implementation autocasts all equipped spells simultaneously. The late-game unlock (via prestige/discipline) that allows manual spell selection is **out of scope for v1**. When implemented it will: - Allow the player to pin one spell per weapon as the "priority" cast. - Other spells on the same weapon continue autocasting normally. - UI: a toggle or pin icon next to each spell in the equipment panel. --- ## 8. Incursion Effects on Combat Incursion (days 20–30) affects **mana regeneration only** — it does not modify enemy stats, spell damage, or golem behaviour directly. ``` effectiveRegen = max(0, baseRegen × (1 - incursionStrength) × meditationMult - conversionCost) ``` At peak incursion (day 30), regen falls to 5% of base. Practical effects: - Spells that cannot be afforded are held (cast timer pauses at 100%). - Golems with unsatisfied maintenance costs are dismissed (see §9.3). - Sword attacks are unaffected (no mana cost). --- ## 9. Golemancy System ### 9.1 Overview Golemancy is the **Fabricator attunement's** combat contribution. Players design custom golems from components (Core + Frame + Mind Circuit + Enchantments), then configure a loadout. Golems are summoned automatically at room entry, fight alongside the player, and disappear after a fixed number of rooms or if their maintenance cost cannot be met. ### 9.2 Golem Loadout (Outside Spire) The player configures a **golem loadout** from the Golemancy tab before entering the spire. The loadout defines which golem designs to attempt to summon and in what order. This configuration persists across rooms but not across spire runs. ### 9.3 Summoning on Room Entry When the player enters a new combat room, `summonGolemsOnRoomEntry()` iterates the loadout in priority order: ``` summonGolemsOnRoomEntry(loadout, rawMana, elements, currentFloor, existingActiveGolems, disciplineSlotsBonus, fabricatorLevel): for each entry in loadout: if !entry.enabled → skip if activeGolems.length >= totalSlots → break // max 7 if already active → skip resolve components (Core, Frame, Mind Circuit) from design stats = computeGolemStats(componentDesign) if player can afford stats.totalSummonCost: deduct summon cost from player mana activeGolems.push({ designId: entry.designId, summonedFloor: currentFloor, attackProgress: 0, roomsRemaining: stats.maxRoomDuration, currentMana: stats.manaCapacity, // starts full spellCastIndex: 0, }) else: log "Not enough mana — skipped" ``` Total slots = `min(7, floor(fabricatorLevel / 2) + disciplineBonus)`. Golems that could not be summoned (insufficient mana) are **not re-attempted** within the same room. They will be attempted again on the next room entry. ### 9.4 Golem Combat Each active golem attacks on its own `attackProgress` timer: ``` attackProgress += HOURS_PER_TICK × frame.attackSpeed while attackProgress >= 1: if mindCircuit has spells && golem.currentMana >= spellCost: cast spell: damage = baseSpellDamage × frame.magicAffinity golem.currentMana -= spellCost spellCastIndex = (spellCastIndex + 1) % selectedSpells.length else: dmg = frame.baseDamage × (1 + frame.armorPierce) apply enchantment effects (burn, slow, etc.) applyDamageToRoom(dmg) attackProgress -= 1 ``` Golems ignore Executioner and Berserker discipline specials. ### 9.5 Maintenance Cost Each tick, `processGolemMaintenance()` checks upkeep for each active golem: ``` upkeepPerTick = core.manaRegen × 2 × HOURS_PER_TICK if player has enough of core.primaryManaType: deduct upkeepPerTick from player element mana else: dismiss(golem) log "${name} dismissed — insufficient mana for upkeep" ``` A dismissed golem is **not re-summoned mid-room**. It will be re-attempted on the next room entry if mana has recovered. ### 9.6 Room Duration Limit `countdownGolemRoomDuration()` runs on room clear: ``` for each activeGolem: golem.roomsRemaining -= 1 if golem.roomsRemaining <= 0: dismiss(golem) log "${name} has faded after ${maxRoomDuration} rooms" ``` Room duration ticks down on room clear, not on room entry — golems persist through the full room they were summoned in. ### 9.7 Golem Data Shape The runtime active golem type (`RuntimeActiveGolem` in `types/game.ts`): ```typescript interface RuntimeActiveGolem { designId: string; // Reference to the player's GolemDesign summonedFloor: number; // Floor when golem was summoned attackProgress: number; // Progress toward next attack (accumulated) roomsRemaining: number; // Rooms before golem fades currentMana: number; // Current mana in golem's own pool spellCastIndex: number; // For alternating/cycling spell circuits } ``` The serialized design type (`SerializedGolemDesign` in `types/game.ts`): ```typescript interface SerializedGolemDesign { id: string; name: string; coreId: string; frameId: string; mindCircuitId: string; enchantmentIds: string[]; selectedManaTypes: string[]; selectedSpells: string[]; } ``` Golem stats are computed from components via `computeGolemStats()` in `data/golems/utils.ts`, which sums summon costs from all components and derives upkeep from `core.manaRegen × 2`. --- ## 10. In-Game Time Display The current in-game time (day and hour) should be visible during spire combat. Display location: **SpireHeader** or **RoomDisplay** component, shown as a small badge or subtitle, e.g. `"Day 4, Hour 12"` or `"D4 H12"`. The value is read from `gameStore.day` and `gameStore.hour` (already tracked). No new state is needed — only a UI read. This is especially relevant as incursion begins at Day 20, so the player needs to be able to gauge how much time they have left without leaving the spire view. --- ## 11. Known Gaps / Incomplete Features The following are defined in data but not yet wired into the runtime pipeline. They are **in scope for the implementation this spec describes**: | Feature | Where Defined | Status | This Spec's Requirement | |---|---|---|---| | Enemy armor reduction | `EnemyState.armor`, `MODIFIER_CONFIG.armored` | **Implemented** — fixed in issue #285 (was bypassed for melee) | Implement in `onDamageDealt` §5.2 | | Enemy barrier absorption | `EnemyState.barrier`, `MODIFIER_CONFIG.mage/shield` | **Implemented** — fixed in issue #285 (was bypassed for melee) | Implement in `onDamageDealt` §5.2 | | Enemy dodge roll | `EnemyState.dodgeChance`, `MODIFIER_CONFIG.agile` | **Implemented** — fixed in issue #285 (was bypassed for melee) | Implement in `onDamageDealt` §5.2 | | Mage barrier recharge | `MODIFIER_CONFIG.mage.barrierRechargeRate` | Data-only | Tick in `onDamageDealt` §5.2 | | Guardian armor | `GuardianDef.armor` | Data-only | Add check to guardian pipeline §5.3 | | DoT / debuff system | Spell/enchantment type defs | **Implemented** — `dot-runtime.ts` complete and wired into combat tick; curse amplification added (issue #286) | Verified working | | Golemancy combat | Full golem data + runtime | **Implemented** — component-based system complete | Verified working | | Sword melee attacks | Weapon type exists | **Implemented** — meleeProgress with enemy defense application (issue #285) | Add `meleeProgress` per §3.1 | | AoE target distribution | `SpellDefinition.aoe` flag | Partial | Implement per §3.2 | | `elemMasteryBonus` | Stub in `calcDamage` | Hardcoded `1` | Future — leave as `1` for now | | `guardianBonus` | Stub in `calcDamage` | Hardcoded `1` | Future — leave as `1` for now | --- ## 12. State Fields (Combat-Relevant) ```typescript // Per-weapon cast timers (replace single castProgress) weaponCastProgress: Record // one entry per equipped weapon // Per-sword melee timers meleeSwordProgress: Record // Active golems activeGolems: ActiveGolem[] // summoned this run // Enemy state extension interface EnemyState { // ...existing fields... activeEffects: ActiveEffect[] // NEW — live debuffs/DoTs effectiveArmor: number // NEW — armor after corrode effects } ``` --- ## 13. Acceptance Criteria | # | Criterion | |---|---| | AC-1 | All equipped spells autocast simultaneously on independent timers — no manual input needed. | | AC-2 | Swords auto-attack on their own timer with no mana cost; elemental matchup applies. | | AC-3 | A player with no equipped weapons still enters the spire (golems-only or empty run). | | AC-4 | Damage formula: base × discipline × boon × elemental × crit produces correct results. | | AC-5 | Elemental matchup applies correctly for both spells and swords. | | AC-6 | Executioner doubles damage when enemy HP < 25%; Berserker grants 1.5× when low on mana. | | AC-7 | Armored enemies reduce damage by their armor percentage. | | AC-8 | Barrier enemies absorb a percentage of each hit before HP is reduced. | | AC-9 | Agile enemies dodge attacks at their dodge chance rate. | | AC-10 | Speed room + agile modifier combines additively for dodge chance (capped at 0.75). | | AC-11 | Guardian shield absorbs flat damage before barrier reduces percentage damage. | | AC-12 | DoT effects (burn, poison, etc.) tick each combat tick and expire after their duration. | | AC-13 | `bypassArmor` effects skip the armor reduction step entirely. | | AC-14 | Golems are summoned on room entry if mana allows; not re-summoned mid-room if dismissed. | | AC-15 | Golem maintenance cost is deducted each tick; golems dismiss if cost cannot be met. | | AC-16 | Golems disappear after `maxRoomDuration` rooms. | | AC-17 | Current in-game time (day + hour) is visible in the spire combat UI. | | AC-18 | Player has no HP, no armor, no healing — combat ends only when all enemies die. | --- ## 14. Files Reference | File | Role | |---|---| | `src/lib/game/stores/combat-actions.ts` | `processCombatTick` — main weapon/golem/DoT loop | | `src/lib/game/stores/pipelines/combat-tick.ts` | `makeOnDamageDealt` — specials + guardian defenses | | `src/lib/game/utils/combat-utils.ts` | `calcDamage`, `calcMeleeDamage`, `getElementalBonus` | | `src/lib/game/utils/enemy-generator.ts` | `selectModifiers`, `applyModifiers`, `MODIFIER_CONFIG` | | `src/lib/game/constants/spells.ts` | Spell registry (all tiers) | | `src/lib/game/constants/elements.ts` | Element list, opposition cycle | | `src/lib/game/constants/core.ts` | `HOURS_PER_TICK`, `INCURSION_START_DAY` | | `src/lib/game/data/guardian-encounters.ts` | Guardian definitions | | `src/lib/game/data/golems/` | Golem definitions (10 golems, tiers 1–4; undergoing redesign — see issue #268) | | `src/lib/game/effects.ts` | `getUnifiedEffects` — merges all combat bonuses | | `src/components/game/tabs/SpireCombatPage/SpireHeader.tsx` | In-game time display | | `src/components/game/tabs/SpireCombatPage/RoomDisplay.tsx` | Room type, enemy state, active effects |