Building a Space Sim in Bevy - Part 10: Combat System
Implementing shields, armor, hull, projectile weapons, combat AI with state machines, and death effects.
Building a Space Sim in Bevy - Part 10: Combat System
Our space is too peaceful.
NPC traders fly around, make money, mind their own business. The player can observe, follow, take over ships. But there’s no tension. No danger. No conflict.
Time to fix that. Today we add combat - shields, weapons, explosions, death. The galaxy gets dangerous.
Design Goals
Before coding, let me explain what I want from combat:
- Layered defenses: Shields → Armor → Hull gives tactical depth
- Projectile weapons: No hitscan lasers - dodging should be possible
- AI that feels alive: States like engaging, attacking, fleeing
- Satisfying destruction: Explosions, wreckage, maybe loot
This isn’t a twitch shooter. It’s a simulation where combat emerges from faction relations and AI decisions.
Architecture
Combat is complex, so I split it into focused sub-plugins:
CombatPlugin
├── HealthPlugin - Shields, Hull, Armor
├── WeaponsPlugin - Weapons, Projectiles
├── CombatAiPlugin - Enemy AI behavior
└── EffectsPlugin - Explosions, Wreckage Each plugin handles one concern. This makes the code navigable and lets us develop features independently.
The Health System
Let’s start with what happens when you get hit. I’m using three separate components:
// src/health/components.rs
use bevy::prelude::*;
/// Marker for combat-capable entities
#[derive(Component, Default)]
pub struct Combatant; The Combatant marker lets us filter for entities that can participate in combat. Not everything should be shootable - stars, UI, the camera.
Shields: Regenerating Protection
Shields are the first line of defense. They absorb damage, then regenerate if you survive:
#[derive(Component, Clone, Debug, Reflect)]
#[reflect(Component)]
pub struct Shield {
pub current: f32,
pub max: f32,
pub regen_rate: f32, // Per second
pub regen_delay: f32, // Seconds after damage before regen starts
pub time_since_damage: f32,
}
impl Shield {
pub fn new(max: f32, regen_rate: f32) -> Self {
Self {
current: max,
max,
regen_rate,
regen_delay: 3.0,
time_since_damage: 3.0, // Start ready to regen
}
}
pub fn take_damage(&mut self, damage: f32) -> f32 {
self.time_since_damage = 0.0; // Reset regen timer
let absorbed = damage.min(self.current);
self.current -= absorbed;
damage - absorbed // Return overflow for hull
}
pub fn regenerate(&mut self, delta: f32) {
self.time_since_damage += delta;
if self.time_since_damage >= self.regen_delay {
self.current = (self.current + self.regen_rate * delta).min(self.max);
}
}
} The take_damage method returns overflow - damage that passed through shields. This is crucial: 100 damage against 30 shields means 70 damage continues to hull.
The regen delay creates interesting gameplay. In a fight, shields won’t regenerate. But if you disengage for a few seconds, you recover. Risk vs. reward.
Hull: Life or Death
Hull is your actual health. When it hits zero, you die:
#[derive(Component, Clone, Debug, Reflect)]
#[reflect(Component)]
pub struct Hull {
pub current: f32,
pub max: f32,
}
impl Hull {
pub fn new(max: f32) -> Self {
Self { current: max, max }
}
pub fn take_damage(&mut self, damage: f32) -> bool {
self.current -= damage;
self.current <= 0.0 // Returns true if destroyed
}
} Simple. Damage goes in, boolean comes out. The caller decides what “destroyed” means.
Armor: Damage Reduction
Armor doesn’t block damage completely - it reduces it. I use two mechanisms:
#[derive(Component, Clone, Debug, Reflect)]
#[reflect(Component)]
pub struct Armor {
pub flat_reduction: f32, // Subtracted first
pub resistance: f32, // 0.0-1.0, percentage reduction
}
impl Armor {
pub fn new(flat: f32, resist: f32) -> Self {
Self {
flat_reduction: flat,
resistance: resist.clamp(0.0, 1.0),
}
}
pub fn reduce_damage(&self, damage: f32) -> f32 {
let after_flat = (damage - self.flat_reduction).max(0.0);
after_flat * (1.0 - self.resistance)
}
} Flat reduction subtracts a fixed amount. Good against small, rapid hits. Resistance reduces by percentage. Good against big hits.
10 damage against 5 flat + 20% resist: (10-5) × 0.8 = 4 damage. This gives armor a meaningful impact without making ships invincible.
Ship Classes
Different ships should feel different. Fighters are fast and fragile. Cruisers are slow and tanky:
#[derive(Component, Default, Clone, Copy, PartialEq, Eq, Debug, Reflect)]
#[reflect(Component)]
pub enum ShipClass {
#[default]
Fighter,
Destroyer,
Cruiser,
}
impl ShipClass {
pub fn stats(&self) -> ShipClassStats {
match self {
ShipClass::Fighter => ShipClassStats {
shield_max: 50.0,
shield_regen: 5.0,
hull_max: 75.0,
armor_flat: 0.0,
armor_resist: 0.0,
thrust: 300.0,
rotation_speed: 7.0,
size: Vec2::new(12.0, 18.0),
},
ShipClass::Destroyer => ShipClassStats {
shield_max: 150.0,
shield_regen: 8.0,
hull_max: 200.0,
armor_flat: 10.0,
armor_resist: 0.1,
thrust: 180.0,
rotation_speed: 4.5,
size: Vec2::new(20.0, 32.0),
},
ShipClass::Cruiser => ShipClassStats {
shield_max: 300.0,
shield_regen: 12.0,
hull_max: 500.0,
armor_flat: 25.0,
armor_resist: 0.25,
thrust: 100.0,
rotation_speed: 2.5,
size: Vec2::new(35.0, 55.0),
},
}
}
} Look at those numbers. A Fighter has 50 shields and 75 hull - a few good hits will kill it. A Cruiser has 300 shields, 500 hull, AND 25% damage resistance. It takes sustained focus fire to bring down.
But the Cruiser is slow. A Fighter can outmaneuver it, stay behind it, maybe even run if things go bad.
Weapon System
No hitscan. Every shot is a physical projectile that has to actually reach its target:
// src/weapons/components.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Reflect)]
pub enum WeaponType {
#[default]
Blaster, // Fast projectile, rapid fire, lower damage
Cannon, // Slow projectile, high damage, slow rate
}
#[derive(Component, Clone, Debug, Reflect)]
#[reflect(Component)]
pub struct Weapon {
pub weapon_type: WeaponType,
pub damage: f32,
pub range: f32,
pub cooldown: f32,
pub cooldown_timer: f32,
pub projectile_speed: f32,
}
impl Weapon {
pub fn blaster() -> Self {
Self {
weapon_type: WeaponType::Blaster,
damage: 15.0,
range: 450.0,
cooldown: 0.5, // 2 shots per second
cooldown_timer: 0.0,
projectile_speed: 800.0, // Fast projectiles
}
}
pub fn cannon() -> Self {
Self {
weapon_type: WeaponType::Cannon,
damage: 50.0,
range: 600.0,
cooldown: 1.5, // Slow rate
cooldown_timer: 0.0,
projectile_speed: 400.0, // Slower but hits harder
}
}
pub fn can_fire(&self) -> bool {
self.cooldown_timer <= 0.0
}
pub fn fire(&mut self) {
self.cooldown_timer = self.cooldown;
}
} Blasters spam fast projectiles - easier to hit but each hit is weak. Cannons are slow and punchy - miss and you’re waiting 1.5 seconds for another shot.
Weapon Input (Decoupled)
Just like ship thrust, weapon firing is decoupled from input:
#[derive(Component, Default)]
pub struct WeaponInput {
pub fire: bool,
pub target: Option<Entity>,
} AI sets WeaponInput, player input sets WeaponInput. The weapon system doesn’t care where the command came from.
Projectiles
When a weapon fires, we spawn a projectile entity:
#[derive(Component)]
pub struct Projectile {
pub damage: f32,
pub owner: Entity,
pub owner_faction: FactionId,
pub lifetime: f32,
} We track the owner to prevent self-hits and owner_faction for faction relation checks. Lifetime ensures projectiles eventually despawn if they don’t hit anything.
Firing Weapons
Here’s the system that actually fires:
pub fn fire_weapons(
mut commands: Commands,
mut shooters: Query<(
Entity,
&Transform,
&mut Weapon,
&WeaponInput,
&FactionMember,
)>,
) {
for (entity, transform, mut weapon, input, faction) in shooters.iter_mut() {
if !input.fire || !weapon.can_fire() { continue; }
weapon.fire(); // Start cooldown
// Calculate spawn position and direction
let forward = (transform.rotation * Vec3::Y).truncate();
let spawn_pos = transform.translation.truncate() + forward * 20.0;
let velocity = forward * weapon.projectile_speed;
commands.spawn((
Projectile {
damage: weapon.damage,
owner: entity,
owner_faction: faction.faction.clone(),
lifetime: 3.0,
},
Velocity { linear: velocity, angular: 0.0 },
Sprite {
color: projectile_color(weapon.weapon_type),
custom_size: Some(projectile_size(weapon.weapon_type)),
..default()
},
Transform::from_translation(spawn_pos.extend(0.0)),
));
}
} The projectile spawns slightly ahead of the ship (+ forward * 20.0) so it doesn’t immediately collide with its owner. It inherits direction from the ship’s rotation and flies straight.
Projectile Collision
Now the meaty part - what happens when projectiles hit things:
pub fn check_projectile_hits(
mut commands: Commands,
projectiles: Query<(Entity, &Transform, &Projectile)>,
mut targets: Query<(
Entity,
&Transform,
&FactionMember,
Option<&mut Shield>,
Option<&Armor>,
&mut Hull,
), With<Combatant>>,
relations: Res<FactionRelations>,
) {
for (proj_entity, proj_transform, projectile) in projectiles.iter() {
let proj_pos = proj_transform.translation.truncate();
for (target_entity, target_transform, target_faction, shield, armor, mut hull)
in targets.iter_mut()
{
// Don't hit yourself
if target_entity == projectile.owner { continue; }
// Only hit enemies
let relation = relations.get(&projectile.owner_faction, &target_faction.faction);
if relation != FactionRelation::Enemy { continue; }
// Simple circle collision
let target_pos = target_transform.translation.truncate();
let distance = proj_pos.distance(target_pos);
if distance < 20.0 {
// HIT!
let mut damage = projectile.damage;
// Shield absorbs first
if let Some(mut shield) = shield {
damage = shield.take_damage(damage);
}
// Armor reduces remainder
if let Some(armor) = armor {
damage = armor.reduce_damage(damage);
}
// Hull takes final amount
hull.take_damage(damage);
// Remove projectile
commands.entity(proj_entity).despawn();
break; // This projectile is done
}
}
}
} The damage cascade is beautiful:
- 50 damage incoming
- Shield has 30 → absorbs 30, passes 20
- Armor has 10 flat + 10% resist → (20-10) × 0.9 = 9 damage
- Hull takes 9 damage
Each layer matters. Remove shields and that’s 36 damage to hull. Remove armor and shields still only block 30.
Combat AI
Now for the brains. Combat AI uses a state machine:
// src/combat_ai/components.rs
#[derive(Component, Default, Clone, Debug, Reflect)]
#[reflect(Component)]
pub enum CombatAiState {
#[default]
Idle,
Patrolling, // Following waypoints
Engaging, // Moving to intercept target
Chasing, // Pursuing fleeing target
Attacking, // In range, actively firing
Fleeing, // Running away
}
#[derive(Component)]
pub struct CombatTarget {
pub entity: Entity,
pub last_known_position: Vec2,
} Each state has clear meaning. An AI in Attacking state will fire weapons and try to maintain optimal range. An AI in Fleeing state will run away and NOT fire.
Combat Personality
Different NPCs should behave differently:
#[derive(Component, Clone, Debug, Reflect)]
#[reflect(Component)]
pub struct CombatPersonality {
pub detection_range: f32,
pub flee_threshold: f32, // Hull % that triggers fleeing
pub aggression: f32, // 0-1, affects decisions
pub preferred_range: f32, // Where they like to fight
}
impl CombatPersonality {
pub fn aggressive() -> Self {
Self {
detection_range: 600.0,
flee_threshold: 0.1, // Fight to the death
aggression: 0.9,
preferred_range: 200.0, // Get in close
}
}
pub fn balanced() -> Self {
Self {
detection_range: 450.0,
flee_threshold: 0.25, // Flee at 25% hull
aggression: 0.5,
preferred_range: 300.0,
}
}
pub fn defensive() -> Self {
Self {
detection_range: 350.0,
flee_threshold: 0.4, // Run early
aggression: 0.2,
preferred_range: 400.0, // Keep distance
}
}
} An aggressive pirate will charge in and fight to 10% hull. A defensive trader will flee at 40% and try to keep distance. Same systems, very different behavior.
The State Machine
Here’s how states transition:
pub fn combat_state_machine(
mut combatants: Query<(
&mut CombatAiState,
&CombatPersonality,
&Hull,
Option<&CombatTarget>,
)>,
) {
for (mut state, personality, hull, target) in combatants.iter_mut() {
let hull_percent = hull.current / hull.max;
// Universal flee check - overrides everything
if hull_percent < personality.flee_threshold {
*state = CombatAiState::Fleeing;
continue;
}
match *state {
CombatAiState::Idle => {
// Transition to Patrolling handled elsewhere
}
CombatAiState::Patrolling => {
if target.is_some() {
*state = CombatAiState::Engaging;
}
}
CombatAiState::Engaging => {
// Transition to Attacking when in range (detection system)
}
CombatAiState::Attacking => {
if target.is_none() {
*state = CombatAiState::Patrolling;
}
}
CombatAiState::Fleeing => {
// Recovery: if shields regenerated, go back to fight
if hull_percent > personality.flee_threshold + 0.2 {
*state = CombatAiState::Engaging;
}
}
_ => {}
}
}
} The flee check is first - no amount of aggression overcomes “about to die.” The recovery logic lets cowardly ships return to fight if they escape and heal.
Death and Effects
When hull hits zero, we don’t immediately despawn. We add a Dying component:
// src/effects/components.rs
#[derive(Component)]
pub struct Dying {
pub cause: DeathCause,
pub position: Vec2,
}
#[derive(Clone, Debug)]
pub enum DeathCause {
Combat { killer: Option<Entity> },
Environmental,
SelfDestruct,
} Why a component instead of immediate despawn? Because we might want to:
- Award kill credit
- Drop loot based on cargo
- Play different effects for different death types
- Notify other systems (faction reputation, etc.)
Explosions
Now the satisfying part - boom:
#[derive(Component)]
pub struct Explosion {
pub timer: f32,
pub max_scale: f32,
}
pub fn spawn_explosions(
mut commands: Commands,
dying: Query<(Entity, &Dying, &Transform)>,
) {
for (entity, dying, transform) in dying.iter() {
let pos = transform.translation;
// Spawn visual explosion
commands.spawn((
Explosion {
timer: 0.0,
max_scale: 50.0,
},
Sprite {
color: Color::srgba(1.0, 0.8, 0.2, 1.0), // Orange-yellow
custom_size: Some(Vec2::splat(10.0)),
..default()
},
Transform::from_translation(pos),
));
// Spawn wreckage
commands.spawn((
Wreckage {
credits: 50,
cargo: HashMap::new(),
decay_timer: 30.0,
},
Sprite {
color: Color::srgba(0.5, 0.5, 0.5, 0.7), // Grey debris
custom_size: Some(Vec2::splat(15.0)),
..default()
},
Transform::from_translation(pos),
));
// Finally despawn the dying entity
commands.entity(entity).despawn_recursive();
}
} The explosion starts small and grows while fading:
pub fn update_explosions(
mut commands: Commands,
time: Res<Time>,
mut explosions: Query<(Entity, &mut Explosion, &mut Transform, &mut Sprite)>,
) {
for (entity, mut explosion, mut transform, mut sprite) in explosions.iter_mut() {
explosion.timer += time.delta_secs();
let progress = explosion.timer / 0.5; // 0.5 second duration
if progress >= 1.0 {
commands.entity(entity).despawn();
} else {
// Grow and fade simultaneously
let scale = explosion.max_scale * progress;
transform.scale = Vec3::splat(scale);
sprite.color.set_alpha(1.0 - progress);
}
}
} Half a second of expanding, fading fireball. Simple but effective.
The Complete Combat Flow
Let’s trace a complete encounter:
- Detection: Patrol AI spots enemy in detection range
- State change: Patrolling → Engaging
- Approach: AI navigates toward target
- In range: Engaging → Attacking
- Fire: WeaponInput.fire = true, projectile spawns
- Flight: Projectile moves via Velocity component
- Impact: Collision detected, damage cascade triggers
- Shields down: Shield absorbs some, overflow continues
- Armor: Reduces remaining damage
- Hull damage: Hull.current decreases
- Death check: Hull reaches zero → Dying component added
- Effects: Explosion spawns, wreckage drops
- Cleanup: Dying entity despawned
All these systems are independent, communicating through components and messages. Adding new weapon types, defense systems, or death effects doesn’t require touching existing code.
What We Built
- Layered health system: Shield → Armor → Hull
- Projectile weapons: Real physics, dodgeable shots
- Combat AI state machine: Patrol, engage, attack, flee
- Combat personalities: Aggressive, balanced, defensive
- Death effects: Explosions and wreckage
What We Learned
- Component composition: Shield, Armor, Hull are all optional
- Damage cascading: Each layer transforms damage for the next
- State machines: Clear states with explicit transitions
- Death as a component: Triggers cleanup without coupling systems
- Sub-plugins: Complex features split into focused modules
What’s Next
Combat works! But there’s a problem: everyone attacks everyone. There’s no friend or foe beyond “is it me?”
In Part 11, we build the faction system. Allied traders won’t shoot each other. Pirates will be hostile to everyone. The galaxy gets political.
Choose your side.