Battle Internals Deep Dive
This guide is for Source and Studio edition users who have access to the full MCE C# source code.
Fifteen modules, one orchestrator. BattleManager holds no combat logic of its own — it asks,
and the state machine decides what gets asked next.
This document provides a detailed look at the BattleManager architecture, its 15 modules, the BattleStateMachine, the formulas the battle uses, and how to add new battle features. For the public members see the BattleManager API.
BattleManager Architecture
The BattleManager follows a modular composition pattern. Rather than being a monolithic class with thousands of lines, battle logic is split across focused modules that live on the same GameObject and communicate through the BattleManager hub. The manager declares them with [RequireComponent], so adding a BattleManager to a GameObject adds the whole set:
[RequireComponent(typeof(BattleStateMachine))]
[RequireComponent(typeof(BattleManagerBattlersModule))]
[RequireComponent(typeof(BattleManagerBattlerSwitchModule))]
[RequireComponent(typeof(BattleManagerBattlerStatsModule))]
[RequireComponent(typeof(BattleManagerHealthModule))]
[RequireComponent(typeof(BattleManagerMovesModule))]
[RequireComponent(typeof(BattleManagerItemsModule))]
[RequireComponent(typeof(BattleManagerCaptureModule))]
[RequireComponent(typeof(BattleManagerRostersModule))]
[RequireComponent(typeof(BattleManagerAIModule))]
[RequireComponent(typeof(BattleManagerScenariosModule))]
[RequireComponent(typeof(BattleManagerStatusesModule))]
[RequireComponent(typeof(BattleManagerCharactersModule))]
[RequireComponent(typeof(BattleManagerAnimationModule))]
[RequireComponent(typeof(BattleManagerAudioModule))]
[RequireComponent(typeof(BattleManagerMegaModule))]
public class BattleManager : OpenMonBehaviour<BattleManager>, IBattleManager, IPlayerDataReceiver
{
// Module accessors, resolved from sibling components:
internal BattleManagerBattlersModule Battlers => GetCachedComponent<BattleManagerBattlersModule>();
internal BattleManagerHealthModule BattlerHealth => GetCachedComponent<BattleManagerHealthModule>();
internal BattleManagerMovesModule Moves => GetCachedComponent<BattleManagerMovesModule>();
// ...BattleManagerBattlerSwitch, BattlerStats, Items, Capture, Rosters, AI,
// Scenario, Statuses, Characters, Animation, Audio and Megas.
}
BattleManagerModule Base Class
All modules inherit from the generic BattleManagerModule<T>:
public abstract class BattleManagerModule<T> : OpenMonBehaviour<T>
where T : BattleManagerModule<T>
{
protected BattleManager BattleManager => GetCachedComponent<BattleManager>();
// The same accessors the manager has, so modules can call into each other:
protected BattleManagerBattlersModule Battlers => BattleManager.Battlers;
protected BattleManagerHealthModule BattlerHealth => BattleManager.BattlerHealth;
protected BattleManagerMovesModule Moves => BattleManager.Moves;
// ...
}
A module therefore needs no registration code and no Initialize call: it is a MonoBehaviour on the battle GameObject, and it reaches the others through the inherited accessors. GetCachedComponent<T>() (from OpenMonBehaviour) finds any sibling component.
Module Deep Dive
BattleManagerBattlersModule
File: BattleManagerBattlersModule.cs
Resolves battlers to and from their battle indices and sprites, handles form changes and running away. A Battler (in Battler.cs) is a MonsterInstance with the in-battle state on top:
public class Battler : MonsterInstance
{
public SerializableDictionary<Stat, short> StatStage;
public SerializableDictionary<BattleStat, short> BattleStatStage; // accuracy, evasion...
public byte CriticalStage;
public SubstituteData Substitute;
public Dictionary<VolatileStatus, int> VolatileStatuses;
public List<Battler> BattlersFought;
public List<Move> MovesUsedSinceEnteringBattle;
public bool EnteredBattleThisTurn;
// ...hooks such as OnCalculateStat, OnStatChange, CanSwitch, GetTypes, GetAbility
}
Key methods: GetBattlerFromBattleIndex(type, index), GetTypeAndIndexOfBattler(battler), GetBattlersFighting(type), IsBattlerFighting, ChangeForm(...), RunAway(...).
BattleManagerMovesModule
File: BattleManagerMovesModule.cs
Runs a move: PerformMove(type, index, targets, moveIndex) (slot 0-3, 4 for Struggle) executes the whole effect pipeline, ForcePerformMove is used by moves that call other moves. The damage is not computed here but by the move itself (DamageMove.CalculateMoveDamage), through the pure helpers of DamageMath.
The damage pipeline (DamageMath, Gen V+):
// Base: (2 * level / 5 + 2) * power * attack/defense * externalPowerMultiplier / 50 + 2
float baseMultiplier = DamageMath.ComputeBaseMultiplier(level, power, attackDefense, externalPowerMultiplier);
// Every factor is computed by the move and multiplied in one place:
float damage = DamageMath.ComputeDamage(baseMultiplier,
targets, // 0.75 (SpreadMultiplier) when hitting several targets
weather, // from the active Weather
terrain, // from the active Terrain
critical, // 1.5 (CriticalMultiplier) on a critical hit
random, // between 0.85 (MinRandom) and 1
stab, // 1.5 (StabMultiplier) x ability changes
typeEffectiveness, // product over the defender's types
status, // e.g. Burn returns 0.5 for physical moves
additional); // abilities, held items, side statuses, allies
int final = DamageMath.RoundDamage(damage); // rounded, never below 1
The additional factor is accumulated through hooks: the user's OnCalculateMoveDamageWhenUsing, the allies' hooks, the target's OnCalculateMoveDamageWhenTargeted and the target side's SideStatus hooks, each receiving the running product and returning the new one.
BattleManagerHealthModule
File: BattleManagerHealthModule.cs
All HP changes flow through this module:
ChangeLife(battler, user, amount, ...)-- the canonical way to damage or heal a battler: runs the hit animation and audio, triggers abilities and reports(appliedAmount, fainted).CheckFaintedBattlers()-- detects battlers whose HP hit zero.ProcessFaintedBattlers()-- plays the faint animations and resolves them (XP, EVs, replacements).
BattleManagerStatusesModule
File: BattleManagerStatusesModule.cs
Manages four families of statuses, each a ScriptableObject you can extend:
| Family | Class | Lives on |
|---|---|---|
| Status condition | Status (Burn, Poison, BadlyPoison, Paralysis, Freeze, Sleep) | one per battler |
| Volatile | VolatileStatus | a battler |
| Side | SideStatus | one side of the field |
| Global | GlobalStatus | the whole field |
Weather and terrain are part of the battle scenario (BattleManagerScenariosModule) rather than of this module. A Status is a set of virtual hooks. Burn, for example, overrides OnStatusAddedInBattle, OnStatusTickInBattle (the damage at turn end), OnCalculateMoveDamage (0.5 for physical moves) and PlayAnimation; the others available are CanAddStatus, OnStatusRemovedInBattle, OnAboutToPerformMove, OnCalculateStat, AboutToBeHitByMove and AfterHitByMove.
The module's API is AddStatus(...) and RemoveStatus(...) (one overload per family), HasStatus(...) and TriggerStatusesCountdown(), which the state machine calls once per turn.
BattleManagerCaptureModule
File: BattleManagerCaptureModule.cs, formulas in BattleUtils.cs
TryCaptureMonster(index, ball) runs a capture against a wild enemy, with the ball throw and the shake animations. The probability comes from BattleUtils.CalculateShakeProbability:
// The modified catch rate:
float catchRate = ((3 * maxHP - 2 * currentHP) * rate * ballMultiplier + ballAddition)
/ (3 * maxHP)
* statusBonus // Status.CatchMultiplier
* Mathf.Max((30 - level) * 0.1f, 1); // low-level bonus
// One shake succeeds with this probability (x the game's catch-difficulty multiplier):
float shake = Mathf.Floor(65536 / Mathf.Pow(255 / catchRate, 0.1875f)) / 65535f;
A ball flagged NeverFails always shakes. Online, the number of successful shakes is decided by the server (see Battle Authority).
BattleUtils.RunChance(allySpeed, enemySpeed, attempts) is the matching formula for running away from a wild battle.
BattleStateMachine
File: BattleStateMachine.cs
The state machine is a plain coroutine loop (BattleLoop), started by BattleManager with StartBattleLoop(). It is not an enum of states: each pass of the while (!BattleManager.IsBattleOver) loop is one turn.
while (!BattleManager.IsBattleOver)
{
Battlers.UpdateBattlersFought();
yield return RequestActions(result => actions = result); // player menu + AIs
// Server-authoritative battle: the server resolves the turn and the loop shows the result.
// Otherwise:
yield return DetermineActionOrder(actions, ...); // priority, then speed
yield return PerformActions();
yield return AfterTurnPreStatusCallbacks(BattlerType.Ally);
yield return AfterTurnPreStatusCallbacks(BattlerType.Enemy);
yield return Statuses.TriggerStatusesCountdown();
yield return AfterTurnPostStatusCallbacks(BattlerType.Ally);
yield return AfterTurnPostStatusCallbacks(BattlerType.Enemy);
yield return ResetBattlersTurnFlags(BattlerType.Ally);
yield return ResetBattlersTurnFlags(BattlerType.Enemy);
yield return BattlerHealth.CheckFaintedBattlers();
yield return BattlerHealth.ProcessFaintedBattlers();
// ...end of the turn
}
yield return Animation.BattleEndAnimation();
yield return Characters.GivePlayerPriceMoney();
yield return Characters.TakePlayerDefeatMoney();
yield return BattleEndCallbacks();
(The listing is abridged; read the file for the complete loop, including the server-authoritative branch.)
Adding New Battle Modules
To add a module to the battle system:
Step 1: Create the Module Class
using System.Collections;
using OpenMon.MCE.Battle;
using UnityEngine;
public class BattleTurnLogModule : BattleManagerModule<BattleTurnLogModule>
{
private int turnsLogged;
public IEnumerator LogLeads()
{
Battler ally = Battlers.GetBattlerFromBattleIndex(BattlerType.Ally, 0);
Battler enemy = Battlers.GetBattlerFromBattleIndex(BattlerType.Enemy, 0);
turnsLogged++;
Debug.Log($"Turn {turnsLogged}: {ally.Species.name} vs {enemy.Species.name}");
yield break;
}
}
Step 2: Put It Next to the BattleManager
Add the component to the BattleManager's GameObject in the battle scene. (If you want it to come with every BattleManager, also add [RequireComponent(typeof(BattleTurnLogModule))] to the manager and a typed accessor next to the others.)
Step 3: Call It
From the state machine, from another module or from a hook:
yield return BattleManager.GetCachedComponent<BattleTurnLogModule>().LogLeads();
Hooking Into a Battle Without Events
The battle does not publish C# events between modules. The extension points are:
| You want to... | Extend |
|---|---|
| React to the start and end of a battle from the game | IBattleSystem (OnBattleStarted, OnBattleEnded) or BattleManager.SubscribeToBattleInitialized / SubscribeToBattleFinished |
| Change what a trainer does | A BattleAI ScriptableObject (Battle AI) |
| Change damage, stats or turn effects | A Status, Weather, Terrain, Ability, Item or Move ScriptableObject: they all expose OnCalculate..., OnStatusTick..., AfterHitByMove-style hooks the loop calls |
| Add a whole new phase to the turn | A new module called from BattleStateMachine |
Testing Battle Logic
The pure parts of the battle are plain static functions, so they test without a scene. The repository's edit-mode suite (MCE.Editor.Tests, in Assets/OpenMon/Core/Tests/Editor/) does this in files such as DamageFormulaTests.cs:
[Test]
public void ComputeDamage_MultipliesEveryFactor()
{
// Base 100, hitting one target, no weather or terrain, no crit, max roll,
// STAB (x1.5) and super effective (x2), no status, nothing extra.
float damage = DamageMath.ComputeDamage(100f, 1f, 1f, 1f, 1f, 1f,
DamageMath.StabMultiplier, 2f, 1f, 1f);
Assert.AreEqual(300f, damage, 0.001f);
}
[Test]
public void RoundDamage_NeverBelowOne() => Assert.AreEqual(1, DamageMath.RoundDamage(0.2f));
Best Practices
- Keep modules focused. Each module should handle one responsibility.
- Reach siblings through the base class accessors, not through scene lookups.
- Put rules in ScriptableObjects (statuses, abilities, items, moves) rather than in the loop: they are testable and reusable.
- Test damage and capture math with known inputs and expected outputs through the pure helpers.
- Do not modify
Battlerstate directly -- go through the module that owns that concern (ChangeLifefor HP, the statuses module for statuses). - Use the state machine for turn sequencing. Do not add ad-hoc coroutines.