Skip to content
reacttypescriptgamedevstate-machinepokemon

Building a Pokemon Battle Engine with useReducer

Basil Francis Alajid
February 15, 2026
3 min read (634 words)β€”

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.

typescript
const [battleState, dispatch] = useReducer(battleReducer, initialState);

The State Shape

Everything the engine needs to resolve any action lives in one object:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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.

typescript
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.

typescript
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.

0
0
0
0
0

Share

Enjoyed this post?

Follow along for more engineering deep dives and project breakdowns.

No spam, ever.

|RSS

Comments

Comments coming soon.

You found the bottom! 🎯