DnD 5e combat engine implementation guide for TORRE. Contains exact formulas, file locations, implementation order, and combat flow spec for BattleScreen and related services. Use when implementing combat mechanics, working on BattleScreen, ReportScreen, or the dungeon battle loop. Keywords: combat, battle, dnd5e, initiative, hit roll, damage, HP, turns, BattleScreen, ReportScreen.
Goal: BattleScreen receives real context. Rooms get marked. Floors advance.
src/navigation/types.ts// Add to RootStackParamList:
Battle: { roomId: string; roomType: RoomType };
Report: { roomId: string; roomWasCleared: boolean };
Import RoomType from dungeonGraphService.ts.
src/screens/MapScreen.tsxFind the handleEnterRoom or equivalent function that navigates to BattleScreen.
navigation.navigate('Battle', {
roomId: selectedRoom.id,
roomType: selectedRoom.type
});
src/screens/BattleScreen.tsxconst route = useRoute<RouteProp<RootStackParamList, 'Battle'>>();
const { roomId, roomType } = route.params;
// On combat end (mock victory for now):
navigation.navigate('Report', { roomId, roomWasCleared: true });
src/screens/ReportScreen.tsxconst route = useRoute<RouteProp<RootStackParamList, 'Report'>>();
const { roomId, roomWasCleared } = route.params;
// On "Continuar" button — navigate BACK to Map, not to Extraction:
navigation.navigate('Map'); // MapScreen will read the updated state
src/screens/MapScreen.tsx (second edit)On focus/return — check if last visited room needs to be marked:
// After navigating back from ReportScreen:
const updatedFloor = markRoomCleared(floorState, roomId);
updateGameStore({ mapState: JSON.stringify(updatedFloor) });
Make sure dungeonGraphService.ts has a markRoomCleared(state, roomId) utility.
Create src/services/combatEngine.ts — pure functions, no side effects, fully testable.
// Core functions to implement:
rollInitiative(actors: CombatActor[], seed: string): CombatActor[]
resolveAttack(attacker: CombatActor, defender: CombatActor, seed: string): AttackResult
calculateDamage(attacker: CombatActor, isCritical: boolean): number
applyDamage(actor: CombatActor, damage: number): CombatActor
isCombatOver(actors: CombatActor[]): { over: boolean; partyWon: boolean }
type CombatActor = {
id: string;
name: string;
isPlayer: boolean;
hp: number;
maxHp: number;
ac: number;
attackMod: number; // STR or DEX mod + proficiency bonus
damageDice: string; // e.g., '1d6', '2d4'
damageMod: number; // stat modifier for damage
initiative: number; // assigned after rollInitiative()
speed: number;
proficiencyBonus: number;
}
type AttackResult = {
hit: boolean;
critical: boolean;
roll: number; // d20 result (for log display)
totalAttack: number; // roll + attackMod + profBonus
damage: number; // 0 if miss
}
type CombatLog = {
round: number;
actorId: string;
actorName: string;
action: string; // e.g., "attacks Goblin for 8 damage!"
result: AttackResult | null;
}
initiative = d20_seeded(seed + actorId) + DEX_modifier
Sort descending — ties broken by DEX modifier, then by player actor (player goes first on tie).
attack_roll = d20_seeded(seed + round + actorId + 'attack')
total_attack = attack_roll + attackMod // attackMod includes proficiency bonus
hit = total_attack >= defender.ac
critical = attack_roll === 20
miss = attack_roll === 1 // auto-miss (natural 1)
Clamp hit probability: minimum 5%, maximum 95%.
base_damage = roll_dice(damageDice, seed + round + actorId + 'damage') + damageMod
final_damage = critical ? base_damage * 2 : base_damage
final_damage = max(final_damage, 1) // always deal at least 1 damage
if actor.hp <= 0:
actor is defeated
if isPlayer: mark as DEAD in party state
Use the existing backgroundSeed.ts PRNG utilities. Always pass a deterministic seed to all random rolls so replays are consistent.
import { seededRandom } from '../services/backgroundSeed';
Get enemies from monsterEvolutionService.ts:
import { getEnemiesForRoom } from '../services/monsterEvolutionService';
const enemies = getEnemiesForRoom({
roomType, // from route params
floor: activeGame.floor,
cycle: activeGame.cycle,
seed: activeGame.seed,
});
type BattleState = {
phase: 'SETUP' | 'COMBAT' | 'VICTORY' | 'DEFEAT';
round: number;
turnOrder: string[]; // actor IDs in initiative order
currentActorIndex: number;
actors: Record<string, CombatActor>;
log: CombatLog[];
}
1. SETUP: build CombatActor[] from party + enemies
2. Roll initiative → sort turnOrder
3. COMBAT phase:
a. Current actor (player or AI) takes Action
b. For player: show attack button, target selector
c. For AI: auto-resolve with combatEngine.resolveAttack()
d. Log the result
e. Check isCombatOver() after each action
f. Advance to next actor (cycle turnOrder)
4. VICTORY: navigate to Report({ roomId, roomWasCleared: true })
5. DEFEAT: navigate to Report({ roomId, roomWasCleared: false })
Use monsterEvolutionService.ts XP decay formula:
const xpEarned = calculateXpReward(enemies, floor, cycle);
const goldEarned = Math.floor(xpEarned * 0.3); // base ratio
| File | Role in Combat |
|---|---|
src/screens/BattleScreen.tsx | Combat UI, turn management |
src/screens/ReportScreen.tsx | Post-combat results display |
src/services/combatEngine.ts | NEW Pure combat logic |
src/services/monsterEvolutionService.ts | Enemy stats + XP rewards |
src/services/characterStats.ts | Player character stats |
src/services/backgroundSeed.ts | Seeded RNG |
src/navigation/types.ts | Route params for Battle + Report |
src/stores/gameStore.ts | Update party HP after combat |
Day 1: combatEngine.ts — rollInitiative, resolveAttack, calculateDamage
Day 2: BattleScreen.tsx — state shape, SETUP phase, turn order display
Day 3: BattleScreen.tsx — COMBAT phase, player action, AI auto-resolve
Day 4: BattleScreen.tsx — VICTORY/DEFEAT, navigate to Report with results
Day 5: ReportScreen.tsx — display real results (XP, gold, casualties)
Day 6: gameStore.ts — persist updated HP/deaths to DB after combat
Day 7: Integration test + bug fixes