Life Total Management in Commander
This document covers the implementation of life total management in the Commander format within Rummage.
Commander Life Total Rules
In the Commander format, players have the following life total rules:
- Starting life total is 40 (compared to 20 in standard Magic)
- A player loses if their life total is 0 or less
- A player can gain life above their starting total with no upper limit
- Life totals are tracked for each player throughout the game
- There is no life loss from drawing from an empty library (unlike in standard Magic)
Additionally, Commander has a unique "Commander damage" rule:
- A player loses if they've been dealt 21 or more combat damage by a single commander
Implementation
Life Total Component
#![allow(unused)] fn main() { /// Component for tracking player life totals #[derive(Component, Debug, Clone, Reflect)] pub struct LifeTotal { /// Current life value pub current: i32, /// Starting life value pub starting: i32, /// Damage taken from each commander (entity ID -> damage amount) pub commander_damage: HashMap<Entity, i32>, } impl Default for LifeTotal { fn default() -> Self { Self { current: 40, // Commander starts at 40 life starting: 40, commander_damage: HashMap::new(), } } } }
Life Change Events
#![allow(unused)] fn main() { #[derive(Event, Debug, Clone)] pub enum LifeChangeEvent { Gain(Entity, i32), // (Player entity, amount) Loss(Entity, i32), // (Player entity, amount) Set(Entity, i32), // (Player entity, new value) CommanderDamage { target: Entity, // Target player source: Entity, // Commander entity amount: i32, // Damage amount }, } }
Life Total System
#![allow(unused)] fn main() { /// System that handles life total changes pub fn handle_life_changes( mut players: Query<(Entity, &mut LifeTotal)>, mut life_events: EventReader<LifeChangeEvent>, mut game_events: EventWriter<GameEvent>, ) { let mut changed_players = HashSet::new(); // Process all life change events for event in life_events.read() { match event { LifeChangeEvent::Gain(entity, amount) => { if let Ok((_, mut life)) = players.get_mut(*entity) { life.current += amount; changed_players.insert(*entity); } }, LifeChangeEvent::Loss(entity, amount) => { if let Ok((_, mut life)) = players.get_mut(*entity) { life.current -= amount; changed_players.insert(*entity); } }, LifeChangeEvent::Set(entity, value) => { if let Ok((_, mut life)) = players.get_mut(*entity) { life.current = *value; changed_players.insert(*entity); } }, LifeChangeEvent::CommanderDamage { target, source, amount } => { if let Ok((_, mut life)) = players.get_mut(*target) { let current_damage = life.commander_damage.entry(*source).or_insert(0); *current_damage += amount; life.current -= amount; changed_players.insert(*target); } } } } // Check for game loss conditions for entity in changed_players { if let Ok((player_entity, life)) = players.get(entity) { // Check for zero or less life if life.current <= 0 { game_events.send(GameEvent::PlayerLost { player: player_entity, reason: LossReason::LifeTotal, }); } // Check for commander damage for (cmdr, damage) in &life.commander_damage { if *damage >= 21 { game_events.send(GameEvent::PlayerLost { player: player_entity, reason: LossReason::CommanderDamage(*cmdr), }); break; } } } } } }
Life Gain/Loss Display
In the UI, life total changes are displayed with:
- Animated counters that show the direction and amount of life change
- Color-coded visual feedback (green for gain, red for loss)
- Persistent life total display for all players
- Visual warning when a player is at low life
- Commander damage trackers for each opponent's commander
Life Total Interactions
Various cards and effects can interact with life totals:
- Life gain/loss effects: Direct modification of life totals
- Life total setting effects: Cards that set life to a specific value
- Life swapping effects: Cards that exchange life totals between players
- Damage redirection: Effects that redirect damage from one player to another
- Damage prevention: Effects that prevent damage that would be dealt
Testing Life Total Management
We test life total functionality with:
- Unit tests: Verifying the baseline functionality
- Integration tests: Testing interactions with damage effects
- Edge case tests: Testing boundary conditions (very high/low life totals)
- Visual tests: Verifying the UI correctly displays life changes
Example Test
#![allow(unused)] fn main() { #[test] fn test_commander_damage_loss() { // Create a test app with required systems let mut app = App::new(); app.add_systems(Update, handle_life_changes) .add_event::<LifeChangeEvent>() .add_event::<GameEvent>(); // Create player entities let player = app.world.spawn(LifeTotal::default()).id(); let commander = app.world.spawn_empty().id(); // Deal 21 commander damage app.world.send_event(LifeChangeEvent::CommanderDamage { target: player, source: commander, amount: 21, }); // Run systems app.update(); // Check if loss event was sent let events = app.world.resource::<Events<GameEvent>>(); let mut reader = events.get_reader(); let mut found_loss = false; for event in reader.read(&events) { if let GameEvent::PlayerLost { player: p, reason: LossReason::CommanderDamage(cmdr) } = event { if *p == player && *cmdr == commander { found_loss = true; break; } } } assert!(found_loss, "Player should lose to commander damage"); } }
Summary
Life total management in Commander is implemented with a flexible system that:
- Correctly applies the Commander-specific starting life total of 40
- Tracks commander damage separately from regular life changes
- Implements all standard and Commander-specific loss conditions
- Provides clear visual feedback through the UI
- Supports all card interactions with life totals