A turn-based battle system: actors line up by initiative, take turns using skills, apply status effects, and battles end when one faction is wiped out.
StartBattleCommand
└── BattleState created with all actors, initiative-sorted
└── BattleStartedEvent fired
UseBattleSkillCommand (player turn)
└── resolves damage/heal, applies status effects
└── BattleActionResolvedEvent, BattleTurnAdvancedEvent
ExecuteAiTurnCommand (enemy turn — controlled by AI policy)
└── same shape as UseBattleSkillCommand, target chosen by AiTargetPolicy
… each round wrap: TurnOrder is re-sorted by current effective initiative …
BattleEndedEvent
└── Rewards (gold/items/loot table) granted atomically
TurnOrder is set at battle start by Initiative descending (tiebreak: ascending actor
id), then re-sorted at every round wrap using each actor's current effective
initiative stat — so equipment, status effects (Slow, Quicken), and any other modifier
that touches the initiative stat all shift turn order between rounds.
GameState.ActiveBattle is non-null while a battle is in progress. Save during a battle
is unsupported by design — take snapshots between battles.
using Moonforge.Core.Combat;
using Moonforge.Core.Combat.Commands;
using Moonforge.Core.Economy.Commands;
dispatcher.Dispatch(gameState, new StartBattleCommand(
battleId: "battle.tutorial.01",
actors:
[
new BattleActorDefinition(
actorId: "party.hero",
displayName: "Hero",
faction: CombatFaction.Party,
maxHp: 40, atk: 12, def: 6, matk: 4, mdef: 5,
initiative: 18,
skillIds: ["skill.strike", "skill.heal"],
playerControlled: true),
new BattleActorDefinition(
actorId: "enemy.slime.01",
displayName: "Slime",
faction: CombatFaction.Enemy,
maxHp: 18, atk: 5, def: 1, matk: 2, mdef: 1,
initiative: 8,
skillIds: ["skill.bite"],
playerControlled: false,
aiPolicy: new BattleAiPolicyDefinition(
rules: [],
fallbackSkillId: "skill.bite",
fallbackTargetPolicy: BattleAiTargetPolicy.LowestHpEnemy))
],
skills:
[
new BattleSkillDefinition("skill.strike", BattleSkillEffectType.PhysicalDamage, power: 8),
new BattleSkillDefinition("skill.heal", BattleSkillEffectType.Heal, power: 10),
new BattleSkillDefinition("skill.bite", BattleSkillEffectType.PhysicalDamage, power: 4)
],
seed: 12345,
rewardCurrency: [new CurrencyDelta("gold", 20)],
rewardLootTableId: "loot.slime"), context);seed is the per-battle RNG seed. rewardCurrency and rewardLootTableId are granted
atomically when the battle ends in victory.
// Whose turn is it? Returns the actor id at the front of the current TurnOrder.
string currentActorId = new GetCurrentBattleTurnActorQueryHandler()
.Query(gameState, new GetCurrentBattleTurnActorQuery());
if (currentActorId == "party.hero")
{
// Player turn: you choose the skill and the explicit target.
dispatcher.Dispatch(gameState, new UseBattleSkillCommand(
actorId: "party.hero",
skillId: "skill.strike",
targetActorId: "enemy.slime.01"), context);
}
else
{
// Enemy turn: let the AI policy pick the skill and target.
dispatcher.Dispatch(gameState, new ExecuteAiTurnCommand(currentActorId), context);
}Both commands publish BattleActionResolvedEvent (skill + target + amount) followed by
BattleTurnAdvancedEvent (next actor's id).
BattleStatus? status = new GetActiveBattleStatusQueryHandler()
.Query(gameState, new GetActiveBattleStatusQuery());
// BattleStatus: Active, Victory, Defeat
if (status == BattleStatus.Active)
{
// ... continue
}The full state is accessible directly when you need it:
BattleState battle = gameState.ActiveBattle!; // non-null only while a battle is in progress
foreach (BattleActorState actor in battle.Actors.Values)
{
Console.WriteLine($"{actor.DisplayName}: {actor.Hp}/{actor.MaxHp}");
}A BattleSkillDefinition can:
- Deal damage:
BattleSkillEffectType.PhysicalDamage(usesatk/defby default) orMagicalDamage(usesmatk/mdef). - Heal:
BattleSkillEffectType.Heal(usesmatkas the healing scalar). - Apply only:
BattleSkillEffectType.Buff(ally-targeted, no HP change) orDebuff(enemy-targeted, no HP change). Effect is carried entirely throughappliesStatuses. - Hit multiple targets:
targetModecontrols fan-out —Single(default, uses the caller-supplied target),Self,AllAllies,AllEnemies,AllOthers. For non-Singlemodes the command's target id is ignored. - Miss:
accuracyPercent(default 100) is rolled per target. On a miss the effect is skipped for that target and aBattleActionMissedEventfires. - Randomize damage / heal:
damageVariancePercent(default 0) jitters the rolled amount by ± that percent (e.g. 15 → multiplier in[85, 115]). Immune targets still resolve to zero. - Critical hits:
critChancePercent(default 0, damage skills only) andcritMultiplierPercent(default 200 = 2×) — when a crit lands, the base damage is multiplied before variance jitter andBattleActionResolvedEvent.WasCriticalis true. Heals never crit. - Cooldown:
cooldownTurnsblocks re-use for N turns after firing. - Cost resources:
resourceCostsconsumes fromBattleActorState.Resources(e.g. Focus / Mana). - Apply status effects:
appliesStatusesrolls per-application chances to apply aStatusEffectDefinitionto the target (or self). - Specify a damage type:
damageTypeIdoverrides the default lookup, routing through the registeredDamageTypeDefinitionfor resistance/immunity handling.
new BattleSkillDefinition(
"skill.firebolt",
BattleSkillEffectType.MagicalDamage,
power: 14,
cooldownTurns: 2,
resourceCosts: new Dictionary<string, int> { ["focus"] = 1 },
displayName: "Fire Bolt",
appliesStatuses:
[
new StatusApplicationDefinition("status.burn", StatusApplicationTarget.Target, chancePercent: 35)
],
damageTypeId: StandardDamageTypes.Fire);targetMode controls how the engine resolves who the skill hits. For Single (default),
the caller passes the explicit target id with UseBattleSkillCommand. For every other
mode, the command's targetActorId is ignored and the engine fans the effect across the
matching actors in turn-order — one roll set per target.
// Fire Nova: every enemy takes a Fire-typed magical hit, with some variance.
new BattleSkillDefinition(
"skill.fire_nova",
BattleSkillEffectType.MagicalDamage,
power: 9,
displayName: "Fire Nova",
targetMode: BattleSkillTargetMode.AllEnemies,
damageTypeId: StandardDamageTypes.Fire,
damageVariancePercent: 15);
// Group heal: heals every living ally; full-HP allies are skipped silently.
new BattleSkillDefinition(
"skill.mass_cure",
BattleSkillEffectType.Heal,
power: 10,
targetMode: BattleSkillTargetMode.AllAllies);
// Self-buff: applies Bulwark to the caster.
new BattleSkillDefinition(
"skill.bulwark",
BattleSkillEffectType.Buff,
power: 0,
targetMode: BattleSkillTargetMode.Self,
appliesStatuses:
[
new StatusApplicationDefinition("status.bulwark", StatusApplicationTarget.Self)
]);Single-target validation rules still apply: trying to heal a full-HP ally with a Single
skill fails with ValidationFailed. For AoE heal the same condition silently filters the
ally out instead — the cast still succeeds on whoever's wounded.
All rolls are deterministic from the battle's seeded RNG (BattleRngState), so the
same battle re-runs identically. The order per target is: accuracy → crit → variance →
status applications. A miss skips all of the remaining rolls. An immune target
(resistance ≥ 100) resolves to 0 damage without consuming the crit or variance rolls.
The per-skill AccuracyPercent, CritChancePercent, and CritMultiplierPercent are
the base values; actor stats fold in additively at resolution time so equipment and
buffs can shift the rolls:
| Stat id | StandardStats const | Source | Effect |
|---|---|---|---|
acc |
Accuracy |
attacker | effectiveAccuracy = skill.AccuracyPercent + attacker.acc - target.eva |
eva |
Evasion |
target | (see above — subtracts from attacker's roll) |
crit |
CritChance |
attacker | effectiveCritChance = skill.CritChancePercent + attacker.crit |
critdmg |
CritDamage |
attacker | effectiveMultiplier = skill.CritMultiplierPercent + attacker.critdmg |
acc and crit clamp to [0, 100]. critdmg floors at 100 (no sub-1× crits). Stats
default to 0 if the actor has no stat block, so existing skills without actor stat
seeding behave exactly as before.
// 80% chance to land; on a miss, BattleActionMissedEvent fires and the effect is
// skipped (no damage, no status applications).
new BattleSkillDefinition(
"skill.hypnosis",
BattleSkillEffectType.Debuff,
power: 0,
accuracyPercent: 80,
appliesStatuses:
[
new StatusApplicationDefinition("status.sleep", chancePercent: 100)
]);
// ±20% damage roll — same seed reproduces the same number.
new BattleSkillDefinition(
"skill.wild_swing",
BattleSkillEffectType.PhysicalDamage,
power: 12,
damageVariancePercent: 20);
// 15% chance to crit for 2× damage. The crit roll happens after accuracy and before
// variance; the resolved event's WasCritical flag is true when it lands.
new BattleSkillDefinition(
"skill.precise_strike",
BattleSkillEffectType.PhysicalDamage,
power: 9,
critChancePercent: 15,
critMultiplierPercent: 200);Immunity (resistance ≥ 100) wins over crit and variance: an immune target still resolves to 0 damage, never the floored 1.
See stats.md for the full pipeline. The short version: register a
DamageTypeDefinition declaring which stats are read for attack, flat defense, and percent
resistance. The combat runtime looks up the skill's DamageTypeId (or the effect-type
default) and applies the formula:
damage = (atk + power - flatDefense) × (100 - resistance) / 100
(clamped to 0 if resistance ≥ 100, else minimum 1)
definitions
.AddDamageType(new DamageTypeDefinition(
StandardDamageTypes.Fire,
attackStatId: "matk",
flatDefenseStatId: null, // pure-resistance type
resistanceStatId: StandardStats.ResistanceFire)); // "res.fire"
// Make an enemy fire-immune:
gameState.ActorStatsState.GetOrCreate("enemy.demon")
.SetBase(StandardStats.ResistanceFire, 100);For Pokemon-style 2× / 0.5× / 0× type matchups, register a
TypeEffectivenessChartDefinition and point a damage type at it. The chart multiplier
layers on top of the existing flat-and-percent resistance math.
definitions.AddTypeEffectivenessChart(new TypeEffectivenessChartDefinition(
id: "chart.elemental",
entries: new[]
{
new TypeEffectivenessEntry("type.fire", "type.grass", 200), // 2× super
new TypeEffectivenessEntry("type.fire", "type.water", 50), // 0.5× resisted
new TypeEffectivenessEntry("type.electric", "type.ground", 0), // immunity
// ... whole-percent multipliers, unlisted matchups default to 100
}));
// Damage type opts into the chart by id.
definitions.AddDamageType(new DamageTypeDefinition(
id: "type.fire",
attackStatId: "matk",
flatDefenseStatId: "mdef",
resistanceStatId: "res.fire",
effectivenessChartId: "chart.elemental")); // ← newEach actor's DefenderTypeIds list (set on BattleActorDefinition) is what the chart
matches against:
new BattleActorDefinition(
actorId: "wild.bellsprout",
/* ... */,
defenderTypeIds: new[] { "type.grass", "type.poison" }); // dual-type defenderStacking rule: multiple defender types stack multiplicatively. Fire vs. Grass/Steel = 2× × 2× = 4×. Water/Ground vs. Electric = 2× × 0× = immune. A 0× entry short-circuits the whole calculation to zero.
damage = (atk + power - flatDefense) × (100 - resistance) / 100 // existing
× chart.GetMultiplierPercent(damageTypeId, target.DefenderTypeIds) / 100
(clamped to 0 if chart returns 0, else minimum 1)
Damage types without an EffectivenessChartId skip the chart step entirely — existing
combat tests that don't set up a chart continue to behave identically.
Pokemon-style "send out a different monster" mid-fight. The current-turn party actor
leaves the field and a fresh actor takes their place. Costs the swapper's turn (matching
the way UseBattleSkillCommand consumes a turn).
using Moonforge.Core.Combat.Commands;
// Build the bench member as a full BattleActorDefinition (your code owns this mapping).
BattleActorDefinition incoming = BuildActorFromBenchMember("party.cleric.001");
dispatcher.Dispatch(gameState, new SwapBattleActorCommand(
outActorId: "party.hero.001", // the current-turn Party actor leaving the field
inActor: incoming), context); // the fresh actor entering — consumes the swapper's turnEngine rules:
- Outgoing actor must be current-turn and Party-faction.
- Outgoing actor must not be silenced/stunned (
PreventsActionstatus). - Incoming actor's definition must be Party-faction.
- Incoming actor must not already be in this battle (no duplicates).
- Incoming actor's
SkillIdsmust all be in the battle's skill registry.
Events:
BattleActorSwappedEvent(battleId, outActorId, inActorId) — fires on success.- The built-in
PartyActiveSyncReactor(Party) watches this and flips theIsActiveflag on whichever side of the swap belongs toPartyState. Wild-vs-wild or scripted-NPC swaps silently no-op there.
Per-skill PP carries across: the engine persists the outgoing actor's PP before removal (if they're tracked, see PP section below) and hydrates the incoming actor's PP from state.
The genre-defining action: roll to add an enemy to your roster. Counts as the capturer's turn — failed captures still advance time, mirroring Pokemon.
using Moonforge.Core.Combat.Commands;
// Setup: tag the target with a non-zero base capture rate on its definition.
new BattleActorDefinition(
actorId: "wild.pidgey.001",
/* ... */,
captureBaseRate: 50); // 0-100, 0 = uncapturable (default, covers bosses)
// During battle:
dispatcher.Dispatch(gameState, new AttemptCaptureCommand(
actorId: "party.hero.001",
targetActorId: "wild.pidgey.001",
bonusPercent: 150 /* ball-quality multiplier */), context);Formula (Pokemon-shaped, simplified to whole-percent):
hpFactor = (3·maxHp − 2·hp) / (3·maxHp) // ~0.33 at full HP, 1.0 at 0 HP
chance% = clamp(baseRate × hpFactor × bonus/100, 0..100)
captured = battle.Rng.NextInt(100) < chance%
BonusPercent is the at-call modifier — fold ball quality, status (asleep / frozen),
habitat bonuses, etc. into the number you pass. The engine doesn't model balls or items
directly.
Validation rules:
- Capturer must be current-turn and Party-faction.
- Target must be an enemy, alive, and have
CaptureBaseRate > 0. - The Party module's
PartyCaptureReactoradds the captured actor to the roster as a reserve. If the party is full the reactor fails and the entire capture rolls back (no RNG burned, no ball "consumed"). CheckMembers.Count < MaxRosterbefore letting the player try.
Events:
BattleActorCapturedEvent— success. CarriesHpAtCapture,MaxHpAtCapture,CapturedSpeciesId(drives bestiary), andCapturedSkillPp(drives PP persistence).CaptureAttemptFailedEvent— miss. CarriesRolledChancePercentfor telemetry.
A successful capture removes the target from the battle's Actors. If they were the
last enemy, the battle ends Victory through the usual UpdateBattleEndState path — no
XP/loot from the captured target (matches Pokemon: "captured ≠ defeated").
See Party → Capture interplay for the cross-module flow.
Pokemon-style "Power Points": each move has a MaxPp cap and an integer counter that
decrements on every use. PP persists across battles for actors the game opts into
tracking; wild enemies stay untracked and start each battle at full PP without polluting
state.
new BattleSkillDefinition(
id: "move.ember",
effectType: BattleSkillEffectType.MagicalDamage,
power: 6,
damageTypeId: "type.fire",
maxPp: 25, // ← 0 (default) = unlimited
displayName: "Ember");MaxPp = 0 is the engine's "unlimited" sentinel — skills without PP set behave
identically to pre-PP combat. No backward-compat breakage.
Out of the box, the SkillPpAutoTrackReactor opts party-joiners and captured monsters
into persistence:
PartyMemberAddedEvent→EnsureTracked(actorId).BattleActorCapturedEvent→EnsureTracked+ write the capture-moment PP.
For other actors (NPC allies, debug seeds), call directly:
using Moonforge.Core.Combat.Commands;
dispatcher.Dispatch(gameState, new EnsureSkillPpTrackingCommand("party.scripted_npc"), context);For "PokeCenter rest" or items that refill moves:
// All tracked skills for the actor, refilled to MaxPp. Requires an active battle so the
// engine can look up each skill's MaxPp.
dispatcher.Dispatch(gameState, new RestoreSkillPpCommand(
actorId: "party.starter.001",
skillId: null,
amount: null), context);
// One skill, exact amount. Caps at MaxPp.
dispatcher.Dispatch(gameState, new RestoreSkillPpCommand(
actorId: "party.starter.001",
skillId: "move.ember",
amount: 10), context);Outside an active battle the engine can't resolve MaxPp on its own — pass an
explicit amount per skill (look it up from your move catalog). The sample game's
BetweenBattles step shows the pattern.
- PP hydrates from state at battle start; falls back to
MaxPpif the actor isn't tracked or has no stored value for that skill. - PP validates before use — out-of-PP skills fail with
InsufficientResourcesandSkillPpDepletedEventfires when a use takes PP to zero. - PP persists back to state at battle end (for tracked actors only), on swap-out, and
on capture. Untracked actors never leave footprints in
ActorSkillPpState.
A StatusEffectDefinition describes an effect that applies for N turns:
definitions.AddStatusEffect(new StatusEffectDefinition(
id: "status.poison",
durationTurns: 3,
tickHpDelta: -2, // -2 HP per tick
displayName: "Poison",
stackPolicy: StatusStackPolicy.RefreshDuration));
definitions.AddStatusEffect(new StatusEffectDefinition(
id: "status.bulwark",
durationTurns: 4,
statModifiers: new Dictionary<string, int> { ["def"] = 4 }, // +4 DEF while active
displayName: "Bulwark"));
definitions.AddStatusEffect(new StatusEffectDefinition(
id: "status.stun",
durationTurns: 1,
preventsAction: true, // skip your turn
displayName: "Stun"));Apply via skill side-effect or directly:
dispatcher.Dispatch(gameState, new ApplyStatusEffectCommand(
actorId: "enemy.slime.01",
statusId: "status.poison",
durationTurns: 3), context);
// Cleanse:
dispatcher.Dispatch(gameState, new RemoveStatusEffectCommand(
actorId: "party.hero",
statusId: "status.poison"), context);Events:
StatusAppliedEvent— when the effect lands.StatusTickedEvent— once per tick (start of the actor's turn).StatusExpiredEvent— when duration runs out or the effect is removed.StatusPreventedActionEvent— whenpreventsAction=trueskipped a turn.
Enemies use a BattleAiPolicyDefinition to choose actions. The runtime evaluates rules
in priority order and falls back to the default if no rule matches:
new BattleAiPolicyDefinition(
rules:
[
// Heal self when below 40% HP.
new BattleAiRuleDefinition(
skillId: "skill.heal",
priorityWeight: 100,
targetPolicy: BattleAiTargetPolicy.Self,
conditions: [new BattleAiConditionDefinition(BattleAiConditionType.SelfHpBelowPercent, 40)]),
// Otherwise, throw bolts at the player when the player is wounded.
new BattleAiRuleDefinition(
skillId: "skill.bolt",
priorityWeight: 50,
targetPolicy: BattleAiTargetPolicy.HighestThreatEnemy,
conditions: [new BattleAiConditionDefinition(BattleAiConditionType.AnyEnemyHpBelowPercent, 80)])
],
fallbackSkillId: "skill.claw",
fallbackTargetPolicy: BattleAiTargetPolicy.LowestHpEnemy);Condition types (BattleAiConditionType): SelfHpBelowPercent, AnyAllyHpBelowPercent,
AnyEnemyHpBelowPercent. Target policies (BattleAiTargetPolicy): Self, LowestHpAlly,
LowestHpEnemy, HighestThreatEnemy, RandomEnemy.
Actors can carry resources separate from HP, refreshed each turn:
new BattleActorDefinition(
actorId: "party.hero",
/* ... hp/atk/def/matk/mdef/initiative ... */
skillIds: ["skill.firebolt"],
playerControlled: true,
resourceMaxes: new Dictionary<string, int> { ["focus"] = 3 }, // pool cap
startingResources: new Dictionary<string, int> { ["focus"] = 3 }, // start full
resourceRefreshPerTurn: new Dictionary<string, int> { ["focus"] = 1 }); // +1 per turnSkills with resourceCosts consume on use; insufficient resources fail the command.
| Event | Use it for |
|---|---|
BattleStartedEvent |
Open the battle screen, animate intro |
BattleTurnAdvancedEvent |
Highlight the new active actor |
BattleActionResolvedEvent |
Floating damage text, hit FX (Amount = 0 means immune, WasCritical = true triggers crit FX) |
BattleActionMissedEvent |
Show "Miss!" floater; suppress damage / status FX |
StatusAppliedEvent / StatusExpiredEvent |
Show / hide status icons |
StatusTickedEvent |
DOT tick FX |
BattleEndedEvent |
Close battle screen, show rewards, read post-battle HP from FinalActorHp / FinalActorMaxHp |
The runtime sets gameState.ActiveBattle = null as soon as the battle's status leaves
Active — in the command handler, before the dispatcher returns. That means any code
running after the dispatcher (party-wipe detection, persistent health bars, save-state
sync) can't read actor HP from ActiveBattle.Actors[id].Hp because the battle is already
gone.
BattleEndedEvent carries the snapshot itself for this reason:
sink.OnEvent<BattleEndedEvent>(end =>
{
foreach ((string actorId, int hp) in end.FinalActorHp)
{
_currentHpByActor[actorId] = hp;
}
});FinalActorHp is keyed by actor id and only contains actors still in the battle at the
moment of close — captured wilds and other mid-battle removals aren't there.
FinalActorMaxHp is the matching MaxHp snapshot if you need to derive a ratio.
- Stats — the modifier pipeline, derived stats, damage types in full
- Loot — battle rewards via
rewardLootTableId - Progression — wiring XP/level-up from kills
- Party — roster state, active vs. reserve, mid-battle swap interplay
- Evolution — level-up triggered species evolution
- Bestiary — auto-tracked encounter/capture codex per species
- Cookbook — multi-enemy battles, themed bosses, status combos