A Battle Engine on useReducer
The battle simulator took the longest to build out of everything in the app. Full singles with the Gen V+ damage formula, type effectiveness, abilities, held items, weather, terrain, stat stages, status conditions, Mega Evolution, Terastallization, and Dynamax. And the whole thing is a useReducer.
That sounds like a weird choice for a game engine. It is. But it's also the right one for this specific problem.
Why useReducer Works Here
The battle simulator isn't a 60fps canvas game. It's turn-based, rendered with React components. The UI updates when state changes. That's literally what React does.
useReducer gives me three things for free. Pure functions β the reducer takes current state and an action, returns next state. No side effects, no mutation. Every transition is testable: pass in state, pass in action, assert on output. Determinism β same initial state, same action sequence, same RNG seed, same result every time. This is what makes replays possible: just record the actions and the seed. And React integration β no sync layer, no subscriptions. Every component reads from battleState and dispatches actions directly.
const [battleState, dispatch] = useReducer(battleReducer, initialState);The State Shape
Everything the engine needs to resolve any action lives in one object:
interface BattleState {
player: BattlePokemon;
opponent: BattlePokemon;
weather: Weather | null;
weatherTurns: number;
terrain: Terrain | null;
terrainTurns: number;
turnNumber: number;
phase: "SELECT_ACTION" | "EXECUTING" | "TURN_END" | "BATTLE_OVER";
log: LogEntry[];
rngSeed: number;
}
interface BattlePokemon {
species: string;
level: number;
types: [Type] | [Type, Type];
ability: string;
heldItem: string | null;
nature: Nature;
currentHp: number;
maxHp: number;
stats: Stats;
statStages: StatStages;
moves: Move[];
status: StatusCondition | null;
volatileStatuses: Set<string>;
isMega: boolean;
isTerastallized: boolean;
teraType: Type | null;
isDynamaxed: boolean;
dynamaxTurns: number;
}Stat stages range from -6 to +6. The modifier formula is (2 + stage) / 2 for positive, 2 / (2 + |stage|) for negative. Standard stuff if you know competitive Pokemon.
Actions as a Discriminated Union
Every possible turn action:
type BattleAction =
| { type: "ATTACK"; moveIndex: number; target: "player" | "opponent" }
| { type: "SWITCH"; pokemonIndex: number }
| { type: "USE_ITEM"; item: string; target: number }
| { type: "MEGA_EVOLVE"; moveIndex: number }
| { type: "TERASTALLIZE"; moveIndex: number }
| { type: "DYNAMAX"; moveIndex: number }
| { type: "PROCESS_TURN" }
| { type: "END_TURN" };Mega, Tera, and Dynamax combine a transformation with an attack. The reducer applies the transformation first, then processes the move. That matches how the actual games handle it β you choose to Mega Evolve, then pick a move, and the evolution resolves at the start of the turn before the move executes.
The Damage Formula
I implemented the Gen V+ formula. The core calculation has nested Math.floor calls that matter β the games truncate at specific points, and getting the rounding wrong by even one point cascades into incorrect damage ranges:
function calculateDamage(
attacker: BattlePokemon,
defender: BattlePokemon,
move: Move,
state: BattleState,
rng: () => number
): number {
const level = attacker.level;
const power = getEffectivePower(move, attacker, defender, state);
const isPhysical = move.category === "physical";
const atkStat = isPhysical
? getEffectiveStat(attacker, "atk")
: getEffectiveStat(attacker, "spa");
const defStat = isPhysical
? getEffectiveStat(defender, "def")
: getEffectiveStat(defender, "spd");
// ((2 * Level / 5 + 2) * Power * Atk / Def) / 50 + 2
let damage = Math.floor(
(Math.floor(
(Math.floor(2 * level / 5 + 2) * power * atkStat) / defStat
)) / 50 + 2
);
damage = applyModifiers(damage, attacker, defender, move, state, rng);
return Math.max(1, damage);
}I verified this against online damage calculators for dozens of edge cases. The nested floors are not optional.
Modifiers Chain
STAB, type effectiveness, crits, weather, items, burn β they all chain together as multipliers on the base damage:
function applyModifiers(
baseDamage: number,
attacker: BattlePokemon,
defender: BattlePokemon,
move: Move,
state: BattleState,
rng: () => number
): number {
let damage = baseDamage;
// Random factor (85-100%)
const roll = Math.floor(rng() * 16) + 85;
damage = Math.floor((damage * roll) / 100);
// STAB
const hasStab = attacker.types.includes(move.type) ||
(attacker.isTerastallized && attacker.teraType === move.type);
if (hasStab) damage = Math.floor(damage * 1.5);
// Adaptability doubles STAB to 2x
if (hasStab && attacker.ability === "Adaptability") {
damage = Math.floor((damage / 1.5) * 2);
}
// Type effectiveness
const effectiveness = getTypeEffectiveness(move.type, defender.types);
damage = Math.floor(damage * effectiveness);
// Critical hit
const critRoll = rng();
const critStage = getCritStage(attacker, move);
const critThresholds = [1 / 24, 1 / 8, 1 / 2, 1];
if (critRoll < critThresholds[Math.min(critStage, 3)]) {
damage = Math.floor(damage * 1.5);
}
// Weather
if (state.weather === "sun" && move.type === "fire") damage = Math.floor(damage * 1.5);
else if (state.weather === "sun" && move.type === "water") damage = Math.floor(damage * 0.5);
else if (state.weather === "rain" && move.type === "water") damage = Math.floor(damage * 1.5);
else if (state.weather === "rain" && move.type === "fire") damage = Math.floor(damage * 0.5);
damage = applyItemModifier(damage, attacker, move);
// Burn halves physical damage (unless Guts)
if (attacker.status === "burn" && move.category === "physical" && attacker.ability !== "Guts") {
damage = Math.floor(damage * 0.5);
}
return damage;
}Type effectiveness is an 18x18 lookup. Dual types multiply β Water vs Rock/Ground is 2 x 2 = 4x. Immunities override everything. I store it as a flat map because the 2D array version was unreadable.
Seedable RNG
For deterministic replays, the RNG has to be seedable. I use mulberry32:
function createRNG(seed: number): () => number {
let state = seed;
return () => {
state |= 0;
state = (state + 0x6D2B79F5) | 0;
let t = Math.imul(state ^ (state >>> 15), 1 | state);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}Every random decision β damage roll, crit check, accuracy, secondary effects β consumes the next value from the sequence. Record the seed and the actions, replay the entire battle identically.
The AI
Full minimax is impractical here β the branching factor with four moves, switches, items, and random outcomes is massive. So the AI does a one-turn lookahead: simulate each legal action through the reducer, score the resulting state, pick the highest.
function evaluateAction(action: BattleAction, state: BattleState): number {
const simulated = battleReducer(state, action);
let score = 0;
if (simulated.player.currentHp <= 0) score += 1000;
const damageDealt = state.player.currentHp - simulated.player.currentHp;
score += damageDealt;
const damageTaken = state.opponent.currentHp - simulated.opponent.currentHp;
score -= damageTaken * 0.8;
if (action.type === "ATTACK") {
const move = state.opponent.moves[action.moveIndex];
const eff = getTypeEffectiveness(move.type, state.player.types);
if (eff > 1) score += 50;
}
score += evaluateWeatherBenefit(simulated, "opponent");
return score;
}This is the biggest win of the useReducer architecture. The same reducer that drives the real battle also powers the AI's lookahead. Because it's a pure function, simulating outcomes is just a function call. No side effects, no cleanup.
The AI picks super-effective moves, switches on type disadvantage, goes for KOs when it can. It's not going to win on Smogon, but for a web app it makes sensible decisions.
Turn Flow
Player selects an action. AI selects its action. Both dispatch. The reducer determines turn order from priority and Speed (with Trick Room inversion if active), executes the faster action, checks fainting, executes the second, applies end-of-turn effects (weather damage, poison, Leftovers, terrain countdown), advances the turn counter.
function processTurn(
state: BattleState,
playerAction: BattleAction,
opponentAction: BattleAction
): BattleState {
let next = { ...state, phase: "EXECUTING" as const };
const [first, second] = determineTurnOrder(next, playerAction, opponentAction);
next = executeAction(next, first);
if (!isBattleOver(next)) next = executeAction(next, second);
next = applyEndOfTurnEffects(next);
next = { ...next, phase: "TURN_END" as const, turnNumber: next.turnNumber + 1 };
if (next.player.currentHp <= 0 || next.opponent.currentHp <= 0) {
next = { ...next, phase: "BATTLE_OVER" as const };
}
return next;
}Every step is a state transformation. No mutation, no hidden state, no callbacks firing at unexpected times. The battle log is an append-only array that tells the story of the fight. Components just render whatever battleState contains.
Building a game engine on useReducer felt wrong at first. Game engines aren't usually React hooks. But for a turn-based system where the UI is the primary interface, it turned out to be almost perfect. Purity, testability, replays, and React integration β all from a hook that ships with the framework.