Skip to content

Battle engine

Single 1v1 battle simulation at Level 100, capped at 60 turns.

Uses the Gen 6 damage formula by default. Pass gen1_mode=True to switch to the Gen 1 stat and damage formula. See run_battle for full parameter details.

battle

Battle engine -- runs a single 1v1 Pokemon battle.

Implements: - Generation-accurate damage formula and type chart via BattleRules - Deterministic AI: always picks the highest expected-damage move - Status moves used when they confer a concrete battle advantage - All status conditions (burn, paralysis, poison, sleep, freeze) - Stat stage tracking - Type effectiveness, STAB, accuracy-weighted damage - No random miss rolls, no critical hits, no damage noise - 60-turn timeout resolved by HP percentage

BattleResult dataclass

BattleResult(
    winner: str,
    loser: str,
    turns: int,
    timeout: bool,
    winner_hp_remaining: int,
    winner_hp_max: int,
    winner_hp_pct: float,
    attacker_had_advantage: bool = False,
)

Outcome of a single 1v1 battle.

run_battle

run_battle(
    pokemon_a: Pokemon,
    pokemon_b: Pokemon,
    level: int = 100,
    rand_ivs: bool = False,
    rng: Random | None = None,
    rules: BattleRules | None = None,
) -> BattleResult

Run a single battle between two Pokemon. Returns a BattleResult with winner, turns, and HP data. Pokemon instances are not mutated -- deep copies are used internally.

The battle is fully deterministic when rand_ivs=False (the default). With rand_ivs=True an rng is used only for IV generation at the start; all in-battle decisions remain deterministic.

rules defaults to Gen6Rules() when not provided.

Source code in pokerena/engine/battle.py
def run_battle(
    pokemon_a: Pokemon,
    pokemon_b: Pokemon,
    level: int = 100,
    rand_ivs: bool = False,
    rng: random.Random | None = None,
    rules: BattleRules | None = None,
) -> BattleResult:
    """
    Run a single battle between two Pokemon.
    Returns a BattleResult with winner, turns, and HP data.
    Pokemon instances are not mutated -- deep copies are used internally.

    The battle is fully deterministic when rand_ivs=False (the default).
    With rand_ivs=True an rng is used only for IV generation at the start;
    all in-battle decisions remain deterministic.

    rules defaults to Gen6Rules() when not provided.
    """
    if rules is None:
        rules = Gen6Rules()
    if rng is None:
        rng = random.Random()

    ivs_a = random_ivs(rng) if rand_ivs else None
    ivs_b = random_ivs(rng) if rand_ivs else None

    a = initialize_battle_state(pokemon_a, level=level, ivs=ivs_a, rules=rules)
    b = initialize_battle_state(pokemon_b, level=level, ivs=ivs_b, rules=rules)

    turns = 0
    while turns < MAX_TURNS:
        turns += 1

        # Determine move order by speed; paralysis halves speed
        spd_a = a.stats.get("speed", 1) * a.stage_multiplier("speed")
        spd_b = b.stats.get("speed", 1) * b.stage_multiplier("speed")
        if a.status == "paralysis":
            spd_a *= 0.5
        if b.status == "paralysis":
            spd_b *= 0.5

        # Speed ties broken by rng -- the only remaining non-IV rng in a
        # deterministic run; ties are rare and have no strategic content
        if spd_a > spd_b:
            first, second = a, b
        elif spd_b > spd_a:
            first, second = b, a
        else:
            first, second = (a, b) if rng.random() < 0.5 else (b, a)

        for attacker, defender in ((first, second), (second, first)):
            if attacker.is_fainted() or defender.is_fainted():
                break

            # Sleep / freeze block action
            if _check_status_skip(attacker):
                continue

            move = _choose_move(attacker, defender, rules=rules)

            if move.category == "status":
                if move.status_effect:
                    _apply_status(defender, move.status_effect)
                if move.stat_changes:
                    _apply_stat_changes(attacker, defender, move.stat_changes)
            else:
                dmg = _calc_damage(attacker, defender, move, level=level, rules=rules)
                defender.current_hp = max(0, defender.current_hp - int(dmg))
                # Deterministic secondary status: apply if the move has one
                # and the target is not already statused
                if move.status_effect and defender.status is None:
                    _apply_status(defender, move.status_effect)

            if defender.is_fainted():
                break

        # End-of-turn status damage and sleep/freeze tick
        _end_of_turn_status(a)
        _end_of_turn_status(b)

        if a.is_fainted() or b.is_fainted():
            break

    # Determine winner
    timeout = turns >= MAX_TURNS and not a.is_fainted() and not b.is_fainted()
    if timeout:
        pct_a = a.current_hp / a.max_hp
        pct_b = b.current_hp / b.max_hp
        winner, loser = (a, b) if pct_a >= pct_b else (b, a)
    elif a.is_fainted():
        winner, loser = b, a
    else:
        winner, loser = a, b

    # Check if winner had a type advantage -- check all of winner's types
    winner_has_adv = any(rules.type_chart.multiplier(t, loser.types) > 1.0 for t in winner.types)

    return BattleResult(
        winner=winner.name,
        loser=loser.name,
        turns=turns,
        timeout=timeout,
        winner_hp_remaining=winner.current_hp,
        winner_hp_max=winner.max_hp,
        winner_hp_pct=winner.current_hp / winner.max_hp,
        attacker_had_advantage=winner_has_adv,
    )