Rummage - MTG Commander Game Engine Documentation
Welcome to the official documentation for Rummage, a robust end-to-end tested Magic: The Gathering Commander format game engine built with Bevy 0.15.x.
About Rummage
Rummage is a modern, open-source implementation of the Magic: The Gathering Commander format, focusing on correctness, performance, and extensibility. Built on Bevy's Entity Component System (ECS) architecture, Rummage provides a solid foundation for complex card interactions while maintaining deterministic gameplay essential for networked multiplayer.
Our goal is to create a comprehensive digital implementation that faithfully reproduces the Commander experience while leveraging modern game engine technologies.
Documentation Structure
The documentation is organized into interconnected sections that guide you from understanding MTG rules to technical implementation details:
- MTG Rules Reference - High-level explanations of Magic: The Gathering rules, serving as a bridge between official rules and our implementation
- MTG Core Rules - Implementation of fundamental Magic: The Gathering rules that form the foundation of all gameplay
- Game Formats - Format-specific rules implementation, currently focusing on the Commander format
- Game UI - User interface systems for visualizing and interacting with the game state
- Networking - Multiplayer functionality using bevy_replicon for synchronized gameplay
- Card Systems - Card representation, effects, and interactions that drive gameplay
- Testing - Comprehensive testing framework to ensure rule correctness and system reliability
- Development - Guidelines and tools for contributors to the Rummage project
- API Reference - Technical documentation of Rummage's code structure and interfaces
These sections follow a logical progression:
- The MTG Rules Reference explains what the rules are
- The MTG Core Rules and Game Formats explain how we implement these rules
- The remaining sections cover the technical systems that support this implementation
Getting Started
If you're new to the project, we recommend exploring the documentation in this order:
- MTG Core Rules Overview - Understand how Rummage implements the fundamental MTG rules
- Commander Format Overview - Learn about the Commander-specific rules and mechanics
- Development Guide - Set up your development environment
- Bevy ECS Guide - Learn how we use Bevy's Entity Component System
- Testing Overview - Understand our testing approach and methodology
Technical Architecture
Rummage integrates several key technologies:
- Bevy 0.15.x - Entity Component System (ECS) game engine that provides the architectural foundation
- Bevy Replicon - Networking and state synchronization for multiplayer gameplay
- Rust - Memory-safe, high-performance language for reliable game logic
Our architecture follows these principles:
- Entity Component System - Game elements are composed of entities with components, providing a flexible and performant structure for representing cards, players, and game state
- Event-driven Architecture - Systems communicate through events, enabling loose coupling and flexible interactions
- Data-oriented Design - Optimized for cache coherence and performance, critical for handling complex board states
- Deterministic Game Logic - Ensures consistency across network play by maintaining predictable state transitions
- Snapshot System - Enables game state serialization for networking, replays, and save/load functionality
Implementation Status
This documentation represents both implemented features and design specifications for planned features. Components are marked as follows:
- ✅ Implemented and tested
- 🔄 In progress
- ⚠️ Planned but not yet implemented
Development Standards
The Rummage codebase adheres to the following standards:
- Bevy 0.15.x Compatibility: Using non-deprecated Bevy APIs (e.g., Text2d instead of Text2dBundle)
- End-to-End Testing: Comprehensive test coverage for all features
- Documentation-First Development: New features are documented before implementation
- Performance Focus: Optimization for smooth gameplay even with complex board states
Contributing
If you're interested in contributing to the Rummage project, please review:
Reference Materials
The implementation is based on official MTG rules:
A local copy of the comprehensive rules is available in this repository: MagicCompRules 20250207.txt.
This documentation will evolve as the project progresses. Last updated: March 2025.
MTG Core Rules
This section documents the implementation of fundamental Magic: The Gathering rules in the Rummage engine.
Overview
The MTG Core Rules module implements the foundational mechanics defined in the Magic: The Gathering Comprehensive Rules, forming the basis for all supported game formats. These include:
- Turn structure and phase sequence
- Card types, characteristics, and properties
- Game zones and zone transitions
- Stack implementation and resolution mechanics
- State-based actions
- Combat system
- Mana and casting costs
Implementation Architecture
Rummage implements MTG rules using Bevy's Entity Component System (ECS) architecture:
MTG Concept | ECS Representation |
---|---|
Cards, permanents, players | Entities |
Card characteristics, states | Components |
Rules procedures, actions | Systems |
Game transitions, triggers | Events |
This architecture creates a clean separation between game data and logic, enabling:
- Higher testability of individual game mechanics
- Parallel processing of independent game systems
- Easier extension for format-specific rules
- Greater code modularity and maintainability
For implementation details, see ECS Implementation.
Core Game Elements
Turn Structure
MTG turns follow a fixed sequence of phases and steps:
-
Beginning Phase
- Untap: Active player untaps their permanents
- Upkeep: Triggers "at beginning of upkeep" abilities
- Draw: Active player draws a card
-
Pre-Combat Main Phase
- Player may play lands and cast spells
-
Combat Phase
- Beginning of Combat: Last chance for effects before attacks
- Declare Attackers: Active player declares attacking creatures
- Declare Blockers: Defending players assign blockers
- Combat Damage: Creatures deal damage
- End of Combat: Triggers "at end of combat" abilities
-
Post-Combat Main Phase
- Player may play lands (if not done in first main phase) and cast spells
-
Ending Phase
- End Step: Triggers "at beginning of end step" abilities
- Cleanup: Damage clears, "until end of turn" effects end
Each phase transition is implemented as a state change, with systems that execute appropriate actions for each phase.
Game Zones
MTG defines distinct zones where cards can exist:
Zone | Description | Implementation |
---|---|---|
Library | Player's deck | Ordered collection, face-down |
Hand | Cards held by a player | Private collection |
Battlefield | Cards in play | Public collection with positioning |
Graveyard | Discarded/destroyed cards | Ordered collection |
Stack | Spells being cast, abilities being activated | LIFO data structure |
Exile | Cards removed from game | Public collection |
Command | Format-specific zone (e.g., Commanders) | Special collection |
Each zone is implemented as an entity with components that track contained cards. Zone transitions trigger specific events and state updates.
Card Types and Characteristics
The engine supports all standard MTG card types:
- Land: Produces mana
- Creature: Can attack and block
- Artifact: Represents magical items
- Enchantment: Ongoing magical effects
- Planeswalker: Powerful ally with loyalty abilities
- Instant: One-time effect at any time
- Sorcery: One-time effect during main phase
Cards are represented as entities with components describing their characteristics (name, types, mana cost, etc.) and current state.
Stack and Priority
The stack is MTG's core mechanic for resolving spells and abilities:
- Active player receives priority first in each step/phase
- Players may cast spells/activate abilities when they have priority
- Spells/abilities go on the stack when cast/activated
- When all players pass priority consecutively, the top item on the stack resolves
- After resolution, active player receives priority again
Our implementation uses a dedicated stack system integrated with a priority manager that tracks which player can act.
State-Based Actions
State-based actions are automatic game rules that check and apply whenever a player would receive priority:
- Creatures with toughness ≤ 0 are put into their owners' graveyards
- Players with life ≤ 0 lose the game
- Auras without legal targets are put into owners' graveyards
- Legendary permanents with the same name are put into graveyards
- And many more...
These are implemented as systems that run at specific points in the game loop to enforce rules consistency.
Format Extensibility
The core rules are implemented in a format-agnostic way to enable:
- Consistent Base Behavior: All formats share the same fundamental mechanics
- Extension Points: Format-specific plugins can override or extend core behavior
- Configuration: Format-specific parameters (starting life, deck requirements, etc.)
For Commander-specific implementations that build upon these core rules, see the Commander Format section.
Technical Components
The core rules implementation includes these key technical components:
- Turn Manager: Controls phase/step progression
- Zone Manager: Handles card movement between zones
- Stack Resolution Engine: Manages spell/ability resolution
- State-Based Action Checker: Enforces automatic game rules
- Combat Resolver: Handles attack/block/damage processes
- Mana System: Tracks and processes mana production/consumption
Each component is implemented as a Bevy plugin that adds relevant systems, components, and resources.
Next Steps
- Turn Structure: Detailed implementation of turn phases and steps
- Zones: Implementation of game zones and zone transitions
- Stack: Stack implementation and priority system
- State-Based Actions: Implementation of automatic game rules
- Combat: Combat phase implementation
- ECS Implementation: Technical details of the ECS approach
Turn Structure
This section documents the core implementation of Magic: The Gathering's turn structure and phase system in Rummage.
Overview
The turn structure in Magic: The Gathering follows a specific sequence of phases and steps, during which players can take actions at defined points. This structured progression is essential for maintaining game order and ensuring rules consistency.
Turn Phases and Steps
A turn in MTG consists of the following phases and steps, in order:
-
Beginning Phase
- Untap Step - Active player untaps their permanents
- Upkeep Step - Triggers "at the beginning of your upkeep" abilities
- Draw Step - Active player draws a card
-
First Main Phase (Precombat Main Phase)
- Player may play lands, cast spells, and activate abilities
-
Combat Phase
- Beginning of Combat Step - Last chance for effects before attackers
- Declare Attackers Step - Active player declares attackers
- Declare Blockers Step - Defending players declare blockers
- Combat Damage Step - Combat damage is assigned and dealt
- End of Combat Step - Last chance for effects before combat ends
-
Second Main Phase (Postcombat Main Phase)
- Player may play lands (if they haven't during this turn), cast spells, and activate abilities
-
Ending Phase
- End Step - Triggers "at the beginning of your end step" abilities
- Cleanup Step - Discard to hand size, damage wears off
Implementation Details
Phase Tracking
#![allow(unused)] fn main() { #[derive(Resource, Debug, Clone, PartialEq, Eq)] pub enum Phase { Beginning(BeginningPhaseStep), Main(MainPhaseType), Combat(CombatStep), Ending(EndingPhaseStep), } #[derive(Debug, Clone, PartialEq, Eq)] pub enum BeginningPhaseStep { Untap, Upkeep, Draw, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum MainPhaseType { Precombat, Postcombat, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum CombatStep { Beginning, DeclareAttackers, DeclareBlockers, CombatDamage, End, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum EndingPhaseStep { End, Cleanup, } }
Phase Progression System
The phase progression system handles the advancement through turn phases:
#![allow(unused)] fn main() { pub fn advance_phase( mut game_state: ResMut<GameState>, mut phase_events: EventWriter<PhaseChangeEvent>, ) { let current_phase = game_state.current_phase.clone(); let next_phase = determine_next_phase(¤t_phase); game_state.current_phase = next_phase.clone(); phase_events.send(PhaseChangeEvent { previous_phase: current_phase, new_phase: next_phase, }); } fn determine_next_phase(current_phase: &Phase) -> Phase { match current_phase { Phase::Beginning(step) => match step { BeginningPhaseStep::Untap => Phase::Beginning(BeginningPhaseStep::Upkeep), BeginningPhaseStep::Upkeep => Phase::Beginning(BeginningPhaseStep::Draw), BeginningPhaseStep::Draw => Phase::Main(MainPhaseType::Precombat), }, Phase::Main(MainPhaseType::Precombat) => Phase::Combat(CombatStep::Beginning), Phase::Combat(step) => match step { CombatStep::Beginning => Phase::Combat(CombatStep::DeclareAttackers), CombatStep::DeclareAttackers => Phase::Combat(CombatStep::DeclareBlockers), CombatStep::DeclareBlockers => Phase::Combat(CombatStep::CombatDamage), CombatStep::CombatDamage => Phase::Combat(CombatStep::End), CombatStep::End => Phase::Main(MainPhaseType::Postcombat), }, Phase::Main(MainPhaseType::Postcombat) => Phase::Ending(EndingPhaseStep::End), Phase::Ending(step) => match step { EndingPhaseStep::End => Phase::Ending(EndingPhaseStep::Cleanup), EndingPhaseStep::Cleanup => Phase::Beginning(BeginningPhaseStep::Untap), }, } } }
Priority System
During most phases and steps, players receive priority to take actions:
#![allow(unused)] fn main() { pub fn handle_priority( game_state: Res<GameState>, mut stack: ResMut<Stack>, mut priority_events: EventWriter<PriorityEvent>, ) { // Skip priority in certain steps if !should_players_get_priority(&game_state.current_phase) { return; } // Determine priority holder let active_player = game_state.active_player; let next_player = determine_priority_player(active_player, &game_state.player_order); priority_events.send(PriorityEvent { player: next_player, phase: game_state.current_phase.clone(), }); } fn should_players_get_priority(phase: &Phase) -> bool { match phase { Phase::Beginning(BeginningPhaseStep::Untap) => false, Phase::Ending(EndingPhaseStep::Cleanup) => false, _ => true, } } }
Extension Points
The turn structure system is designed to be extensible for different formats:
- Format-Specific Phases - Formats can add custom phases or steps
- Turn Modification - Systems for extra turns, skipped turns, or additional phases
- Priority Rules - Customizable priority passing for different formats
Integration with Other Systems
The turn structure integrates with these other game systems:
- State-Based Actions - Checked whenever a player would receive priority
- Stack Resolution - Occurs during priority passes when no player adds to the stack
- Triggered Abilities - Put on the stack at appropriate times based on the current phase
- Mana System - Mana pools empty at phase boundaries
Format-Specific Considerations
Different Magic formats may implement turn structure variations:
- Multiplayer Formats - Handle turn order for multiple players
- Special Formats - May modify turn structure (e.g., Two-Headed Giant)
For Commander-specific turn structure implementation, see Commander Turn Structure.
Next: Priority System
Priority System
This document details the implementation of the priority system in Rummage, which determines which player can take actions at any given time during a game of Magic: The Gathering.
Overview
The priority system is a fundamental part of Magic: The Gathering's turn structure. It determines when players can cast spells, activate abilities, and take other game actions. Understanding and correctly implementing the priority system is essential for proper game flow.
Core Priority Rules
The basic rules of priority in MTG are:
- The active player receives priority first in each step and phase
- When a player has priority, they may:
- Cast a spell
- Activate an ability
- Take a special action
- Pass priority
- When a player passes priority, the next player in turn order receives priority
- When all players pass priority in succession:
- If the stack is empty, the current step or phase ends
- If the stack has objects, the top object on the stack resolves, then the active player gets priority again
Implementation
In Rummage, the priority system is implemented as follows:
#![allow(unused)] fn main() { #[derive(Resource)] pub struct PrioritySystem { // The player who currently has priority pub current_player: Entity, // Set of players who have passed priority in succession pub passed_players: HashSet<Entity>, // Whether the priority system is currently active pub active: bool, } #[derive(Event)] pub struct PriorityEvent { pub player: Entity, pub action: PriorityAction, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PriorityAction { Receive, // Player receives priority Pass, // Player passes priority TakeAction, // Player takes an action (cast spell, activate ability, etc.) } }
Priority Flow
The flow of priority follows this pattern:
- Phase/Step Start: At the beginning of each phase or step, the active player receives priority
- Action Taken: If a player takes an action, all players who have passed are reset, and priority returns to the active player
- Passing: When a player passes, the next player in turn order receives priority
- Resolution: When all players pass in succession, either the top of the stack resolves or the phase/step ends
Priority System Implementation
#![allow(unused)] fn main() { pub fn handle_priority_system( mut commands: Commands, mut priority: ResMut<PrioritySystem>, mut priority_events: EventReader<PriorityEvent>, game_state: Res<GameState>, stack: Res<Stack>, ) { for event in priority_events.iter() { match event.action { PriorityAction::Pass => { // Record that this player passed priority.passed_players.insert(event.player); // Check if all players have passed if priority.passed_players.len() == game_state.players.len() { // All players have passed if !stack.items.is_empty() { // Resolve top of stack commands.add(resolve_stack_command()); // Reset passed players priority.passed_players.clear(); // Active player gets priority again priority.current_player = game_state.active_player; } else { // Stack is empty, end current phase/step commands.add(advance_phase_command()); // Priority will be set by the phase transition system priority.active = false; } } else { // Not all players have passed, give priority to next player let next_player = get_next_player(event.player, &game_state); priority.current_player = next_player; } }, PriorityAction::TakeAction => { // Player took an action, reset passed players priority.passed_players.clear(); // Active player gets priority again priority.current_player = game_state.active_player; }, PriorityAction::Receive => { // Player receives priority (usually at beginning of phase/step) priority.current_player = event.player; priority.active = true; } } } } }
Special Priority Rules
No Priority Phases
Some steps do not normally grant players priority:
- Untap Step: No player receives priority during this step
- Cleanup Step: No player receives priority unless a triggered ability triggers
#![allow(unused)] fn main() { pub fn should_grant_priority(phase: Phase, step: Step) -> bool { match (phase, step) { (Phase::Beginning, Step::Untap) => false, (Phase::Ending, Step::Cleanup) => false, _ => true, } } }
Triggered Abilities During No-Priority Steps
If a triggered ability triggers during a step where players don't normally receive priority, players will receive priority:
#![allow(unused)] fn main() { pub fn handle_cleanup_triggers( mut commands: Commands, mut priority: ResMut<PrioritySystem>, game_state: Res<GameState>, triggers: Res<TriggeredAbilities>, ) { // If we're in cleanup step and there are triggers if game_state.current_phase == Phase::Ending && game_state.current_step == Step::Cleanup && !triggers.pending.is_empty() { // Grant priority to active player priority.active = true; priority.current_player = game_state.active_player; priority.passed_players.clear(); } } }
APNAP Order
When multiple players would receive priority simultaneously (such as for triggered abilities), they are processed in APNAP (Active Player, Non-Active Player) order:
#![allow(unused)] fn main() { pub fn get_players_in_apnap_order(game_state: &GameState) -> Vec<Entity> { let mut players = Vec::new(); // Start with active player let mut current = game_state.active_player; players.push(current); // Add remaining players in turn order for _ in 1..game_state.players.len() { current = get_next_player(current, game_state); players.push(current); } players } }
Integration with Other Systems
The priority system integrates with:
- Turn Structure: Phase and step transitions affect priority
- Stack System: Stack resolution and priority are tightly coupled
- Action System: Player actions affect priority flow
- UI System: The UI must indicate which player has priority
Implementation Status
The priority system implementation currently:
- ✅ Handles basic priority passing
- ✅ Integrates with stack resolution
- ✅ Implements APNAP order
- ✅ Handles special steps without priority
- ✅ Supports triggered abilities during cleanup
- 🔄 Implementing special actions that don't use the stack
- 🔄 Handling priority with split second spells
Next: Turn Phases
Turn Phases
This document details the implementation of turn phases and steps in Rummage, explaining how the game progresses through the structured sequence of a Magic: The Gathering turn.
Phase and Step Structure
A turn in Magic: The Gathering consists of five phases, some of which are divided into steps:
#![allow(unused)] fn main() { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Phase { Beginning, PreCombatMain, Combat, PostCombatMain, Ending, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Step { // Beginning Phase Untap, Upkeep, Draw, // Main Phase (no steps) Main, // Combat Phase BeginningOfCombat, DeclareAttackers, DeclareBlockers, CombatDamage, FirstStrikeDamage, // Only used when first/double strike is involved EndOfCombat, // Ending Phase End, Cleanup, } }
Phase Transitions
The game progresses through phases and steps in a specific order. This is managed by a phase transition system:
#![allow(unused)] fn main() { pub fn phase_transition_system( mut commands: Commands, mut game_state: ResMut<GameState>, mut phase_events: EventWriter<PhaseChangeEvent>, mut step_events: EventWriter<StepChangeEvent>, stack: Res<Stack>, priority: Res<PrioritySystem>, ) { // Only transition if priority is not active and stack is empty if priority.active || !stack.items.is_empty() { return; } // Current phase and step let current_phase = game_state.current_phase; let current_step = game_state.current_step; // Determine next phase and step let (next_phase, next_step) = get_next_phase_step(current_phase, current_step); // Update game state game_state.current_phase = next_phase; game_state.current_step = next_step; // Send events if current_phase != next_phase { phase_events.send(PhaseChangeEvent { previous: current_phase, current: next_phase, }); } step_events.send(StepChangeEvent { previous: current_step, current: next_step, }); // Execute phase/step entry actions execute_phase_entry_actions(next_phase, next_step, &mut commands, &game_state); } }
Phase Progression
The progression from one phase/step to the next follows this pattern:
#![allow(unused)] fn main() { fn get_next_phase_step(current_phase: Phase, current_step: Step) -> (Phase, Step) { match (current_phase, current_step) { // Beginning Phase progression (Phase::Beginning, Step::Untap) => (Phase::Beginning, Step::Upkeep), (Phase::Beginning, Step::Upkeep) => (Phase::Beginning, Step::Draw), (Phase::Beginning, Step::Draw) => (Phase::PreCombatMain, Step::Main), // Pre-Combat Main Phase progression (Phase::PreCombatMain, Step::Main) => (Phase::Combat, Step::BeginningOfCombat), // Combat Phase progression (Phase::Combat, Step::BeginningOfCombat) => (Phase::Combat, Step::DeclareAttackers), (Phase::Combat, Step::DeclareAttackers) => (Phase::Combat, Step::DeclareBlockers), (Phase::Combat, Step::DeclareBlockers) => { // Check if first strike is needed if combat_has_first_strike() { (Phase::Combat, Step::FirstStrikeDamage) } else { (Phase::Combat, Step::CombatDamage) } }, (Phase::Combat, Step::FirstStrikeDamage) => (Phase::Combat, Step::CombatDamage), (Phase::Combat, Step::CombatDamage) => (Phase::Combat, Step::EndOfCombat), (Phase::Combat, Step::EndOfCombat) => (Phase::PostCombatMain, Step::Main), // Post-Combat Main Phase progression (Phase::PostCombatMain, Step::Main) => (Phase::Ending, Step::End), // Ending Phase progression (Phase::Ending, Step::End) => (Phase::Ending, Step::Cleanup), (Phase::Ending, Step::Cleanup) => { // Move to next turn (Phase::Beginning, Step::Untap) }, // Default case (should never happen) _ => (current_phase, current_step), } } }
Phase Entry Actions
Each phase and step has specific actions that occur when entering:
#![allow(unused)] fn main() { fn execute_phase_entry_actions( phase: Phase, step: Step, commands: &mut Commands, game_state: &GameState, ) { match (phase, step) { // Beginning Phase actions (Phase::Beginning, Step::Untap) => { // Untap all permanents controlled by active player commands.add(untap_permanents_command(game_state.active_player)); // Handle "at beginning of untap step" triggers commands.add(check_beginning_of_step_triggers_command(phase, step)); }, (Phase::Beginning, Step::Upkeep) => { // Handle "at beginning of upkeep" triggers commands.add(check_beginning_of_step_triggers_command(phase, step)); // Grant priority to active player commands.add(grant_priority_command(game_state.active_player)); }, (Phase::Beginning, Step::Draw) => { // Active player draws a card (except on first turn) if game_state.turn_number > 1 { commands.add(draw_card_command(game_state.active_player, 1)); } // Handle "at beginning of draw step" triggers commands.add(check_beginning_of_step_triggers_command(phase, step)); // Grant priority to active player commands.add(grant_priority_command(game_state.active_player)); }, // Main Phase actions (Phase::PreCombatMain, Step::Main) | (Phase::PostCombatMain, Step::Main) => { // Reset land plays for turn if entering first main phase if phase == Phase::PreCombatMain && game_state.current_phase != Phase::PreCombatMain { commands.add(reset_land_plays_command()); } // Grant priority to active player commands.add(grant_priority_command(game_state.active_player)); }, // Combat Phase actions (Phase::Combat, _) => { // Handle specific combat step actions match step { Step::BeginningOfCombat => { // Handle "at beginning of combat" triggers commands.add(check_beginning_of_step_triggers_command(phase, step)); }, Step::DeclareAttackers => { // Active player declares attackers commands.add(declare_attackers_command()); }, Step::DeclareBlockers => { // Defending players declare blockers commands.add(declare_blockers_command()); }, Step::FirstStrikeDamage => { // Assign and deal first strike damage commands.add(assign_first_strike_damage_command()); commands.add(deal_combat_damage_command(true)); }, Step::CombatDamage => { // Assign and deal regular combat damage commands.add(assign_combat_damage_command()); commands.add(deal_combat_damage_command(false)); }, _ => {} } // Grant priority to active player (for all combat steps) commands.add(grant_priority_command(game_state.active_player)); }, // Ending Phase actions (Phase::Ending, Step::End) => { // Handle "at beginning of end step" triggers commands.add(check_beginning_of_step_triggers_command(phase, step)); // Grant priority to active player commands.add(grant_priority_command(game_state.active_player)); }, (Phase::Ending, Step::Cleanup) => { // Discard to hand size commands.add(discard_to_hand_size_command(game_state.active_player)); // Remove "until end of turn" effects commands.add(remove_until_end_of_turn_effects_command()); // Clear damage from permanents commands.add(clear_damage_command()); // No priority is granted in cleanup step unless a trigger occurs }, } } }
Turn Advancement
When a turn ends, the game advances to the next player's turn:
#![allow(unused)] fn main() { fn advance_turn_system( mut commands: Commands, mut game_state: ResMut<GameState>, mut turn_events: EventWriter<TurnChangeEvent>, ) { // Only advance turn when transitioning from cleanup to untap if game_state.current_phase == Phase::Ending && game_state.current_step == Step::Cleanup && !game_state.transitioning_to_next_turn { // Mark that we're transitioning to next turn game_state.transitioning_to_next_turn = true; // Get next player let next_player = get_next_player(game_state.active_player, &game_state); // Send turn change event turn_events.send(TurnChangeEvent { previous_player: game_state.active_player, next_player, turn_number: game_state.turn_number + 1, }); // Update game state game_state.active_player = next_player; game_state.turn_number += 1; // Reset turn-based tracking commands.add(reset_turn_tracking_command()); } } }
Extra Turns
The system also supports extra turns, which are handled by modifying the turn order:
#![allow(unused)] fn main() { pub fn add_extra_turn( player: Entity, game_state: &mut GameState, extra_turn_events: &mut EventWriter<ExtraTurnEvent>, ) { // Add extra turn to the queue game_state.extra_turns.push(player); // Send event extra_turn_events.send(ExtraTurnEvent { player, source_turn: game_state.turn_number, }); } fn determine_next_turn_player(game_state: &GameState) -> Entity { // Check if there are extra turns queued if !game_state.extra_turns.is_empty() { // Take the next extra turn return game_state.extra_turns[0]; } // Otherwise, proceed to next player in turn order get_next_player(game_state.active_player, game_state) } }
Implementation Status
The turn structure implementation currently:
- ✅ Handles all standard phases and steps
- ✅ Implements proper phase transitions
- ✅ Supports phase-specific actions
- ✅ Manages turn advancement
- ✅ Supports extra turns
- 🔄 Implementing special turn modifications (e.g., additional combat phases)
- 🔄 Supporting time counters and suspended cards
Next: Combat System
Game Zones
Overview
Zones are distinct areas where cards can exist during a game of Magic: The Gathering. This section documents the core implementation of game zones in Rummage, which serve as the foundation for all MTG formats.
Standard Game Zones
Magic: The Gathering has the following standard game zones:
- Library - The player's deck of cards, face down and in a randomized order
- Hand - Cards held by a player, visible only to that player (unless affected by card effects)
- Battlefield - Where permanents (lands, creatures, artifacts, enchantments, planeswalkers) exist in play
- Graveyard - Discard pile for destroyed, sacrificed, or discarded cards, face up
- Stack - Where spells and abilities wait to resolve
- Exile - Cards removed from the game, face up unless specified otherwise
Additionally, some formats introduce special zones:
- Command Zone - Used primarily in Commander format for commanders and emblems
Core Zone Implementation
Each zone is implemented as an entity with components that track its contained cards. The zone system manages the movement of cards between zones according to the MTG rules.
Zone Management Components
#![allow(unused)] fn main() { #[derive(Resource)] pub struct ZoneManager { // Maps player entities to their personal zones (library, hand, graveyard) pub player_zones: HashMap<Entity, PlayerZones>, // Shared zones (battlefield, stack, exile) pub battlefield: Entity, pub stack: Entity, pub exile: Entity, // Format-specific zones can be added by plugins pub command_zone: Option<Entity>, } #[derive(Component)] pub struct Zone { pub zone_type: ZoneType, pub owner: Option<Entity>, // None for shared zones pub cards: Vec<Entity>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ZoneType { Library, Hand, Battlefield, Graveyard, Stack, Exile, Command, } pub struct PlayerZones { pub library: Entity, pub hand: Entity, pub graveyard: Entity, } }
Zone Transitions
When a card moves from one zone to another, a zone transition occurs. These transitions are managed by the zone system and can trigger abilities based on the zones involved.
Zone Transition Events
#![allow(unused)] fn main() { #[derive(Event)] pub struct ZoneTransitionEvent { pub card: Entity, pub from_zone: Entity, pub to_zone: Entity, pub reason: ZoneTransitionReason, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ZoneTransitionReason { Cast, Resolve, Destroy, Sacrifice, Discard, Draw, ReturnToHand, PutIntoPlay, Exile, ShuffleIntoLibrary, // ...and more } }
Zone Transition System
#![allow(unused)] fn main() { pub fn handle_zone_transitions( mut commands: Commands, mut zone_events: EventReader<ZoneTransitionEvent>, mut zones: Query<&mut Zone>, mut triggered_abilities: EventWriter<TriggeredAbilityEvent>, cards: Query<&Card>, ) { for event in zone_events.iter() { // Remove card from source zone if let Ok(mut source_zone) = zones.get_mut(event.from_zone) { if let Some(index) = source_zone.cards.iter().position(|&c| c == event.card) { source_zone.cards.remove(index); } } // Add card to destination zone if let Ok(mut dest_zone) = zones.get_mut(event.to_zone) { dest_zone.cards.push(event.card); } // Check for triggered abilities based on zone transitions if let Ok(card) = cards.get(event.card) { // Process any "when this card enters/leaves a zone" abilities // ... } } } }
Zone-Specific Rules
Each zone has specific rules that govern how cards interact within and with that zone:
Library
- Cards are face down and in random order
- Players draw from the top
- Running out of cards to draw results in a loss
Hand
- Cards are visible only to their owner
- Hand size is normally limited to 7 at end of turn
- Players discard down to maximum hand size during cleanup
Battlefield
- Only permanents can exist on the battlefield
- Cards on the battlefield are affected by "summoning sickness"
- Most abilities can only be activated from the battlefield
Graveyard
- Cards are face up and can be seen by all players
- Order of cards matters for some effects
- Some abilities function from the graveyard
Stack
- Spells and abilities go on the stack before resolving
- The stack resolves in "last in, first out" order
- All players get priority between stack resolutions
Exile
- Cards are face up by default
- Exiled cards are generally inaccessible
- Some effects allow interaction with exiled cards
Format Extensions
Different formats may extend or modify the basic zone system:
- Commander Format: Adds the Command Zone for commanders
- Planechase: Adds a plane card zone
- Archenemy: Adds a scheme card zone
For format-specific zone mechanics like the Command Zone in Commander, see the respective format documentation.
Next: Zone Transitions
Stack and Priority System
Overview
The stack is a fundamental mechanic in Magic: The Gathering, representing the order in which spells and abilities resolve. This section documents the core implementation of the stack and priority system in Rummage.
The Stack
The stack is a last-in, first-out (LIFO) data structure that determines the order in which spells and abilities resolve during gameplay.
Stack Implementation
#![allow(unused)] fn main() { #[derive(Resource)] pub struct Stack { // The stack itself - a list of stack items in order pub items: Vec<StackItem>, // The currently resolving stack item, if any pub currently_resolving: Option<StackItem>, // Temporary storage for abilities that trigger during resolution pub pending_triggers: Vec<TriggeredAbility>, } #[derive(Clone, Debug)] pub struct StackItem { pub id: Entity, pub item_type: StackItemType, pub controller: Entity, pub source: Entity, pub targets: Vec<StackTarget>, pub effects: Vec<Effect>, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum StackItemType { Spell(CardType), Ability(AbilityType), } #[derive(Clone, Debug, PartialEq, Eq)] pub enum AbilityType { Activated, Triggered, Static, // Static abilities generally don't use the stack but are tracked here for targeting purposes Mana, // Mana abilities don't use the stack but may be tracked here } }
Stack Order and Resolution
The stack follows these core principles:
- The active player gets priority first in each phase and step
- When a player has priority, they can cast spells or activate abilities
- Each spell or ability goes on top of the stack
- After a spell or ability is put on the stack, all players get priority again
- When all players pass priority in succession, the top item of the stack resolves
- After resolution, the active player gets priority again
Stack Resolution System
#![allow(unused)] fn main() { pub fn resolve_stack( mut commands: Commands, mut stack: ResMut<Stack>, mut game_state: ResMut<GameState>, mut zone_transitions: EventWriter<ZoneTransitionEvent>, mut effect_events: EventWriter<EffectEvent>, ) { // Check if there's something to resolve if stack.items.is_empty() { return; } // Get the top item let item = stack.items.pop().unwrap(); stack.currently_resolving = Some(item.clone()); // Process based on item type match item.item_type { StackItemType::Spell(card_type) => { // Process spell resolution // Move card to appropriate zone (battlefield for permanents, graveyard for others) // ... }, StackItemType::Ability(ability_type) => { // Process ability resolution // ... } } // Process effects for effect in item.effects.iter() { effect_events.send(EffectEvent { effect: effect.clone(), source: item.source, controller: item.controller, targets: item.targets.clone(), }); } // Clear currently resolving stack.currently_resolving = None; // Check for triggered abilities that happened during resolution // ... } }
Priority System
The priority system determines which player can take actions at any given time. Priority passes in turn order, starting with the active player.
Priority Implementation
#![allow(unused)] fn main() { #[derive(Resource)] pub struct PrioritySystem { pub current_player: Entity, pub passed_players: HashSet<Entity>, pub active: bool, } #[derive(Event)] pub struct PriorityEvent { pub player: Entity, pub action: PriorityAction, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PriorityAction { Receive, Pass, TakeAction, } }
Priority Passing System
#![allow(unused)] fn main() { pub fn handle_priority( mut commands: Commands, mut priority: ResMut<PrioritySystem>, mut priority_events: EventReader<PriorityEvent>, game_state: Res<GameState>, stack: Res<Stack>, ) { for event in priority_events.iter() { match event.action { PriorityAction::Pass => { // Record that this player passed priority.passed_players.insert(event.player); // Determine next player let next_player = get_next_player_in_turn_order(event.player, &game_state); // Check if all players have passed if priority.passed_players.len() == game_state.player_order.len() { // Resolve top of stack or advance phase if !stack.items.is_empty() { // Resolve top of stack commands.add(resolve_stack_command()); } else { // Advance to next phase commands.add(advance_phase_command()); } // Reset priority system priority.passed_players.clear(); priority.current_player = game_state.active_player; } else { // Pass priority to next player priority.current_player = next_player; } }, PriorityAction::TakeAction => { // Clear passed players since someone took an action priority.passed_players.clear(); // After an action, active player gets priority priority.current_player = game_state.active_player; }, // Other priority actions... } } } }
Special Rules
Mana Abilities
Mana abilities don't use the stack and resolve immediately, meaning they cannot be responded to. This is handled separately from the regular stack system.
Split Second
Some cards have the "Split Second" ability, which means that while they're on the stack, players can't cast spells or activate abilities that aren't mana abilities.
Interrupts and Special Timing
Certain effects can interrupt the normal flow of gameplay, such as:
- Replacement effects
- Prevention effects
- State-based actions
- Special actions that don't use the stack
Format Extensions
Different formats may extend or modify the basic stack system:
- Commander Format: May have format-specific timing rules
- Multiplayer Formats: Must handle priority passing among multiple players
For format-specific stack and priority mechanics, see the respective format documentation.
Next: Stack Resolution
Stack Resolution
This document details the implementation of the stack resolution process in Rummage, explaining how spells and abilities on the stack are processed and resolved.
Resolution Overview
Stack resolution follows the Last-In-First-Out (LIFO) principle. When all players pass priority in succession without taking any actions, the top item on the stack resolves. This process requires careful handling of various card effects, targets, and game state changes.
Resolution Process
1. Prepare for Resolution
The system begins by taking the top item off the stack and preparing it for resolution:
#![allow(unused)] fn main() { fn prepare_for_resolution(stack: &mut Stack, world: &mut World) -> Option<StackItem> { if stack.items.is_empty() { return None; } // Remove the top item from the stack let item = stack.items.pop().unwrap(); // Store the currently resolving item for reference stack.currently_resolving = Some(item.clone()); Some(item) } }
2. Check Targets
Before resolution, the system verifies that all targets are still legal:
#![allow(unused)] fn main() { fn verify_targets(item: &StackItem, world: &World) -> bool { let mut all_targets_legal = true; for target in &item.targets { if !is_legal_target(target, item, world) { all_targets_legal = false; break; } } all_targets_legal } }
If a spell or ability has no legal targets remaining, it is countered by game rules:
#![allow(unused)] fn main() { if !verify_targets(&item, world) { if item.item_type.is_spell() { // Counter the spell events.send(SpellCounteredEvent { spell_id: item.id, countered_by: CounterReason::IllegalTargets, }); // Move spell card to graveyard zone_transitions.send(ZoneTransitionEvent { entity: item.source, from: Zone::Stack, to: Zone::Graveyard, cause: ZoneTransitionCause::Countered, }); } else { // Counter the ability events.send(AbilityCounteredEvent { ability_id: item.id, countered_by: CounterReason::IllegalTargets, }); } return; } }
3. Process Spell Resolution
For spell resolution, the system handles different card types appropriately:
#![allow(unused)] fn main() { fn resolve_spell(item: &StackItem, world: &mut World, events: &mut EventWriter<ZoneTransitionEvent>) { match item.item_type { StackItemType::Spell(CardType::Creature) | StackItemType::Spell(CardType::Artifact) | StackItemType::Spell(CardType::Enchantment) | StackItemType::Spell(CardType::Planeswalker) | StackItemType::Spell(CardType::Land) => { // Permanent spells - move to battlefield events.send(ZoneTransitionEvent { entity: item.source, from: Zone::Stack, to: Zone::Battlefield, cause: ZoneTransitionCause::Resolved, }); }, StackItemType::Spell(CardType::Instant) | StackItemType::Spell(CardType::Sorcery) => { // Non-permanent spells - move to graveyard events.send(ZoneTransitionEvent { entity: item.source, from: Zone::Stack, to: Zone::Graveyard, cause: ZoneTransitionCause::Resolved, }); }, _ => {} // Handle other card types } } }
4. Process Ability Resolution
For ability resolution, the system processes the effects without zone transitions:
#![allow(unused)] fn main() { fn resolve_ability(item: &StackItem, world: &mut World) { // Abilities don't change zones, only their effects are processed match item.item_type { StackItemType::Ability(AbilityType::Activated) | StackItemType::Ability(AbilityType::Triggered) => { // Process ability effects }, _ => {} // Handle other ability types } } }
5. Apply Effects
The system processes each effect of the resolving item:
#![allow(unused)] fn main() { fn apply_effects( item: &StackItem, world: &mut World, effect_events: &mut EventWriter<EffectEvent> ) { for effect in &item.effects { effect_events.send(EffectEvent { effect: effect.clone(), source: item.source, controller: item.controller, targets: item.targets.clone(), }); } } }
6. Post-Resolution Cleanup
After resolution, the system clears the currently resolving item and checks for triggered abilities:
#![allow(unused)] fn main() { fn post_resolution_cleanup(stack: &mut Stack, world: &mut World) { // Clear the currently resolving item stack.currently_resolving = None; // Process pending triggers that occurred during resolution for trigger in stack.pending_triggers.drain(..) { world.spawn_trigger(trigger); } // Check state-based actions check_state_based_actions(world); // Return priority to active player set_priority(world.resource::<GameState>().active_player, world); } }
Special Resolution Cases
Countering Spells and Abilities
When a spell or ability is countered, it never resolves:
#![allow(unused)] fn main() { pub fn counter_spell_system( mut counter_events: EventReader<CounterSpellEvent>, mut stack: ResMut<Stack>, mut zone_transitions: EventWriter<ZoneTransitionEvent>, ) { for event in counter_events.iter() { // Find the spell on the stack if let Some(index) = stack.items.iter().position(|item| item.id == event.target) { // Remove it from the stack let item = stack.items.remove(index); // Move the card to graveyard zone_transitions.send(ZoneTransitionEvent { entity: item.source, from: Zone::Stack, to: Zone::Graveyard, cause: ZoneTransitionCause::Countered, }); } } } }
Split Second
When resolving items with Split Second, special handling prevents responses:
#![allow(unused)] fn main() { pub fn check_split_second(stack: &Stack) -> bool { stack.items.iter().any(|item| { if let StackItemType::Spell(card_type) = &item.item_type { // Check if any spell on the stack has split second let has_split_second = world .get::<Card>(item.source) .map(|card| card.has_ability(Ability::SplitSecond)) .unwrap_or(false); return has_split_second; } false }) } }
Integration with Other Systems
The stack resolution process integrates with:
- Zone System: Moving cards between zones after resolution
- Effect System: Processing the effects of spells and abilities
- Targeting System: Verifying target legality
- Trigger System: Handling triggers that occur during resolution
- State-Based Action System: Checking state-based actions after resolution
Implementation Status
The stack resolution implementation currently:
- ✅ Handles basic spell and ability resolution
- ✅ Processes spell types correctly (permanent vs. non-permanent)
- ✅ Validates targets before resolution
- ✅ Implements spell countering
- ✅ Supports triggered abilities that trigger during resolution
- 🔄 Handling complex resolution effects (choices, conditions, etc.)
- 🔄 Implementing special cases like Split Second
Next: Priority System
State-Based Actions in MTG
Overview
State-Based Actions (SBAs) are one of the foundational mechanisms of Magic: The Gathering's rules system. They serve as automatic checks that the game performs regularly to ensure game rules are properly enforced without requiring explicit player actions. These checks occur whenever a player would receive priority and before any player actually receives priority.
Core Functionality
State-based actions handle many crucial game state validations:
-
Player loss conditions:
- A player with 0 or less life loses the game
- A player attempting to draw from an empty library loses the game
- A player with 10 or more poison counters loses the game (in formats where poison counters are relevant)
-
Creature conditions:
- Creatures with toughness 0 or less are put into their owner's graveyard
- Creatures with damage marked on them greater than or equal to their toughness are destroyed
- Creatures that have been dealt damage by a source with deathtouch are destroyed
-
Permanent-specific rules:
- Planeswalker with no loyalty counters is put into its owner's graveyard
- Auras that aren't attached to anything or attached illegally are put into their owner's graveyard
- Equipment or Fortifications that are attached illegally become unattached
- Legendary permanents with the same name are put into their owner's graveyard except the most recently played one
-
Token and counter rules:
- A token that has left the battlefield ceases to exist
- If a permanent has both +1/+1 and -1/-1 counters on it, they cancel out in pairs
Implementation
The state-based actions system is implemented as a collection of systems that run between steps and phases:
#![allow(unused)] fn main() { #[derive(Resource)] pub struct StateBasedActionSystem { // Configuration for SBA processing pub enabled: bool, pub last_check_time: Duration, pub check_frequency: Duration, // Tracking for specific SBAs pub pending_legendary_checks: Vec<Entity>, pub pending_destruction: Vec<Entity>, pub pending_life_checks: Vec<Entity>, } // Systems that implement specific checks pub fn check_player_loss_conditions(/* ... */); pub fn check_creature_destruction(/* ... */); pub fn check_attachment_validity(/* ... */); pub fn check_legendary_rule(/* ... */); pub fn check_token_existence(/* ... */); pub fn check_counter_interactions(/* ... */); }
Application Order
State-based actions are applied in a specific order, but all applicable state-based actions are performed simultaneously as a single event. If performing state-based actions creates new conditions for state-based actions, those new state-based actions will be checked in the next round of checks.
This is especially important for situations like:
- A player at 0 life with a creature dying simultaneously
- Multiple legendary permanents entering the battlefield at the same time
- Creatures with damage marked receiving -X/-X effects
Multiplayer Considerations
In multiplayer games:
- State-based actions are checked simultaneously for all players and permanents
- When a player leaves the game, all cards they own leave the game
- Objects controlled by a player who left the game are exiled, unless they're controlled by a different player's ongoing effect
Optimization in Rummage
The SBA system in Rummage is optimized to:
- Only check relevant game objects (using spatial partitioning where appropriate)
- Use efficient data structures to track pending actions
- Batch similar checks where possible
- Cache results when appropriate
- Minimize performance impact during complex game states
Related Documentation
- Turn Structure: When state-based actions are checked during the turn
- Zones: How state-based actions interact with game zones
- Combat: Combat-specific state-based actions
Combat System
Overview
The Combat system is a fundamental part of Magic: The Gathering, implemented in Rummage according to the comprehensive rules. This section documents the core combat mechanics that apply to all formats.
Combat Phases
A combat phase consists of the following steps in order:
- Beginning of Combat Step - The last chance for players to cast spells or activate abilities before attackers are declared
- Declare Attackers Step - The active player chooses which creatures will attack and which opponents or planeswalkers they will attack
- Declare Blockers Step - Each defending player chooses which creatures will block and which attacking creatures they will block
- Combat Damage Step - Combat damage is assigned and dealt (with separate first strike and regular damage steps if needed)
- End of Combat Step - The last chance for players to cast spells or activate abilities before the combat phase ends
Core Combat Mechanics
Attacking
- Only untapped creatures can be declared as attackers
- Creatures can't attack their controller
- Creatures can attack players or planeswalkers (unless modified by card effects)
- Creatures with Defender can't attack
- Creatures with Summoning Sickness (entered battlefield this turn) can't attack unless they have Haste
Blocking
- Tapped creatures can't block
- Each blocking creature can only block one attacker (unless modified by card effects)
- Multiple creatures can block a single attacker
- Creatures can only block attackers that are attacking their controller or planeswalkers controlled by their controller
Combat Damage
- Attacking creatures deal damage equal to their power
- Blocking creatures deal damage equal to their power
- If multiple creatures block a single attacker, the attacker's controller assigns the damage among them
- Combat damage is dealt simultaneously (unless first strike or double strike is involved)
- Combat damage to players causes life loss
- Combat damage to planeswalkers removes loyalty counters
- Combat damage to creatures causes damage marked on them
Special Combat Abilities
- First Strike: Creatures with first strike deal combat damage before creatures without first strike
- Double Strike: Creatures with double strike deal damage in both the first strike and regular damage steps
- Trample: Excess combat damage can be assigned to the defending player or planeswalker
- Vigilance: Creatures with vigilance don't tap when attacking
- Deathtouch: Any amount of damage from a creature with deathtouch is enough to destroy the damaged creature
- Lifelink: Damage dealt by a creature with lifelink causes its controller to gain that much life
Implementation Details
The combat system is implemented through a series of components and systems that manage the state of creatures in combat, process combat events, and enforce the rules of combat.
Extensions for Formats
Different formats may extend or modify these core combat mechanics:
- Commander Format: Adds commander damage tracking
- Two-Headed Giant: Modifies how creatures can attack and block
- Planechase: May add special combat rules based on the current plane
For format-specific extensions like Commander's combat rules, see the respective format documentation.
Combat Documentation
- Combat Phases - Detailed explanation of each combat step
- First Strike and Double Strike - How these abilities modify combat
- Combat Damage Calculation - Rules for damage assignment and resolution
Combat Phases
Overview
The combat phase in Magic: The Gathering is divided into five distinct steps, each with specific rules and opportunities for player interaction. This document details how these steps are implemented in Rummage's core engine.
Beginning of Combat Step
The Beginning of Combat step marks the start of the combat phase. During this step:
- "At the beginning of combat" triggered abilities go on the stack
- Players receive priority, starting with the active player
- This is the last opportunity to use effects that would prevent creatures from attacking
Implementation
#![allow(unused)] fn main() { pub fn beginning_of_combat_system( mut commands: Commands, game_state: Res<GameState>, mut phase_events: EventWriter<BeginningOfCombatEvent>, mut triggered_abilities: EventWriter<TriggeredAbilityEvent>, ) { // Generate beginning of combat event phase_events.send(BeginningOfCombatEvent { active_player: game_state.active_player, }); // Check for triggered abilities that trigger at beginning of combat // Add them to the stack // ... } }
Declare Attackers Step
During the Declare Attackers step:
- The active player declares attackers
- Creatures attack as a group
- The active player taps attacking creatures (unless they have vigilance)
- "Whenever a creature attacks" triggered abilities go on the stack
- Players receive priority, starting with the active player
Attacking Rules
- Only untapped creatures controlled by the active player can be declared as attackers
- Each attacking creature must attack either an opponent or a planeswalker an opponent controls
- Creatures with summoning sickness can't attack unless they have haste
- Attacking doesn't use the stack and can't be responded to directly
Implementation
#![allow(unused)] fn main() { pub fn declare_attackers_system( mut commands: Commands, mut game_state: ResMut<GameState>, mut attack_events: EventReader<DeclareAttackEvent>, mut creatures: Query<(Entity, &mut Creature, &Controller)>, mut triggered_abilities: EventWriter<TriggeredAbilityEvent>, ) { // Process attack declarations for attack_event in attack_events.iter() { // Validate attackers (untapped, controlled by active player, etc.) // Record which creatures are attacking and what they're attacking // Tap attacking creatures without vigilance // ... } // Generate triggered abilities for "whenever a creature attacks" // ... } }
Declare Blockers Step
During the Declare Blockers step:
- The defending player(s) declare blockers
- Each blocking creature must block exactly one attacking creature
- "Whenever a creature blocks" triggered abilities go on the stack
- The active player declares the damage assignment order for creatures blocked by multiple creatures
- Players receive priority, starting with the active player
Blocking Rules
- Only untapped creatures can block
- Each creature can block only one attacker (unless it has special abilities)
- Multiple creatures can block a single attacker
- Blocking doesn't use the stack and can't be responded to directly
Implementation
#![allow(unused)] fn main() { pub fn declare_blockers_system( mut commands: Commands, mut game_state: ResMut<GameState>, mut block_events: EventReader<DeclareBlockEvent>, mut creatures: Query<(Entity, &Creature, &Controller)>, mut triggered_abilities: EventWriter<TriggeredAbilityEvent>, ) { // Process block declarations for block_event in block_events.iter() { // Validate blockers (untapped, etc.) // Record which creatures are blocking and what they're blocking // ... } // Generate triggered abilities for "whenever a creature blocks" or "becomes blocked" // ... // Determine damage assignment order for multiple blockers // ... } }
Combat Damage Step
During the Combat Damage step:
- If any creatures have first strike or double strike, a separate First Strike Combat Damage step occurs first
- The active player assigns combat damage from their attacking creatures
- The defending player(s) assign combat damage from their blocking creatures
- All combat damage is dealt simultaneously
- Players receive priority, starting with the active player
Damage Assignment Rules
- Each attacking creature assigns damage equal to its power
- Each blocking creature assigns damage equal to its power
- Blocked creatures assign their damage to the blocking creatures
- Unblocked creatures assign their damage to the player or planeswalker they're attacking
- If multiple creatures block an attacker, the attacker's controller decides how to distribute the damage
Implementation
#![allow(unused)] fn main() { pub fn combat_damage_system( mut commands: Commands, game_state: Res<GameState>, combat_state: Res<CombatState>, mut creatures: Query<(Entity, &Creature, &mut Health)>, mut players: Query<(Entity, &Player, &mut Life)>, mut planeswalkers: Query<(Entity, &Planeswalker, &mut Loyalty)>, ) { // Handle first strike damage if needed // ... // Assign and deal combat damage for (attacker, targets) in combat_state.attackers.iter() { // Calculate damage amount // Apply damage to appropriate targets // Handle special abilities (deathtouch, lifelink, etc.) // ... } // Check for state-based actions after damage // ... } }
End of Combat Step
During the End of Combat step:
- "At end of combat" triggered abilities go on the stack
- Players receive priority, starting with the active player
- After this step, all creatures and planeswalkers are removed from combat
Implementation
#![allow(unused)] fn main() { pub fn end_of_combat_system( mut commands: Commands, game_state: Res<GameState>, mut combat_state: ResMut<CombatState>, mut phase_events: EventWriter<EndOfCombatEvent>, mut triggered_abilities: EventWriter<TriggeredAbilityEvent>, ) { // Generate end of combat event phase_events.send(EndOfCombatEvent { active_player: game_state.active_player, }); // Check for triggered abilities that trigger at end of combat // ... // Remove all creatures from combat combat_state.attackers.clear(); combat_state.blockers.clear(); // ... } }
Format-Specific Extensions
Different MTG formats may extend these basic combat phases with additional rules:
- Commander: Tracks commander damage separately
- Two-Headed Giant: Modifies how combat damage is dealt to players
- Multiplayer Formats: Have special rules for attacking multiple opponents
For details on format-specific combat mechanics, see the respective format documentation.
Next: First Strike and Double Strike
First Strike and Double Strike
Overview
First Strike and Double Strike are two of the most important keyword abilities in Magic: The Gathering that modify how creatures deal combat damage. They create a special combat damage step that occurs before the regular combat damage step.
First Strike
First Strike allows creatures to deal combat damage before creatures without First Strike.
Rules
- Creatures with First Strike deal damage in the "first strike combat damage step" which happens before the regular combat damage step
- Creatures without First Strike or Double Strike don't deal or receive combat damage in this step
- If no creatures with First Strike or Double Strike are involved in combat, this step is skipped entirely
- First Strike doesn't provide any additional advantage in creature-to-creature combat outside of combat damage timing
Implementation
#![allow(unused)] fn main() { #[derive(Component)] pub struct FirstStrike; pub fn handle_first_strike_damage( combat_state: Res<CombatState>, creatures: Query<(Entity, &Creature, &Attack, &BlockedBy, Option<&FirstStrike>, Option<&DoubleStrike>)>, mut health: Query<&mut Health>, mut damage_events: EventWriter<DamageEvent>, ) { // Identify creatures with First Strike or Double Strike let first_strikers = creatures.iter() .filter(|(_, _, _, _, first_strike, double_strike)| first_strike.is_some() || double_strike.is_some()); // Process First Strike damage for (entity, creature, attack, blocked_by, _, _) in first_strikers { // Calculate and assign damage // ... } } }
Double Strike
Double Strike combines the benefits of First Strike with the ability to deal damage again in the regular combat damage step.
Rules
- Creatures with Double Strike deal combat damage in both the first strike combat damage step AND the regular combat damage step
- This effectively allows them to deal twice their power in damage during combat
- If a Double Strike creature is removed from combat after the first strike step (e.g., by being destroyed), it won't deal damage in the regular step
- Double Strike doesn't increase the amount of damage dealt in each individual step - it allows the creature to deal damage in both steps
Implementation
#![allow(unused)] fn main() { #[derive(Component)] pub struct DoubleStrike; pub fn handle_regular_combat_damage( combat_state: Res<CombatState>, creatures: Query<(Entity, &Creature, &Attack, &BlockedBy, Option<&DoubleStrike>)>, mut health: Query<&mut Health>, mut damage_events: EventWriter<DamageEvent>, ) { // Process Regular Combat Damage for (entity, creature, attack, blocked_by, _) in creatures.iter() { // All creatures without First Strike always deal damage here // Creatures with Double Strike also deal damage here // ... } } }
Interactions with Other Abilities
First Strike and Double Strike interact with other combat abilities in specific ways:
- Deathtouch: A creature with both First Strike/Double Strike and Deathtouch can destroy blocking/blocked creatures before they deal damage
- Lifelink: A creature with both Double Strike and Lifelink will cause its controller to gain life twice
- Trample: A creature with both First Strike/Double Strike and Trample can deal excess damage to players in both damage steps
Example Combat Scenarios
First Strike vs. Non-First Strike
When a 2/2 creature with First Strike blocks or is blocked by a 2/2 creature without First Strike:
- First Strike creature deals 2 damage in the first strike damage step
- The other creature is destroyed before it can deal damage in the regular damage step
Double Strike vs. Regular Creature
When a 2/2 creature with Double Strike blocks or is blocked by a 3/3 creature without First Strike:
- Double Strike creature deals 2 damage in the first strike damage step
- The 3/3 creature survives with 1 toughness remaining
- Both creatures deal damage in the regular damage step (2 more from Double Strike, 3 from the regular creature)
- Both creatures are destroyed
Related Documentation
Combat Damage Calculation
Overview
Combat damage calculation is a critical aspect of the Magic: The Gathering combat system. This document explains how damage is calculated, assigned, and applied during combat in Rummage's implementation.
Damage Assignment
Basic Rules
- Each attacking or blocking creature assigns combat damage equal to its power
- Attacking creatures that aren't blocked assign their damage to the player or planeswalker they're attacking
- Blocking creatures assign their damage to the creature they're blocking
- Blocked creatures assign their damage to the creatures blocking them
Multiple Blockers
When multiple creatures block a single attacker:
- The attacking player puts the blocking creatures in a damage assignment order
- The attacker must assign at least lethal damage to each creature in the order before assigning damage to the next creature
- "Lethal damage" is damage equal to the creature's toughness minus damage already marked on it
#![allow(unused)] fn main() { pub fn determine_damage_assignment_order( mut commands: Commands, mut combat_state: ResMut<CombatState>, blocker_groups: Query<(&Creature, &Health, &Blocking)>, ) { for (attacker_entity, blockers) in combat_state.blockers_per_attacker.iter() { if blockers.len() > 1 { // Request damage assignment order from attacking player // Store the order in the combat state // ... } } } }
Damage Application
Once damage is assigned, it is applied simultaneously:
- Damage to creatures is marked on them but doesn't immediately reduce toughness
- Damage to players reduces their life total
- Damage to planeswalkers removes loyalty counters
- Special abilities like deathtouch and lifelink take effect at this time
#![allow(unused)] fn main() { pub fn apply_combat_damage( combat_state: Res<CombatState>, mut creatures: Query<(Entity, &Creature, &mut Health)>, mut players: Query<(Entity, &Player, &mut Life)>, mut planeswalkers: Query<(Entity, &Planeswalker, &mut Loyalty)>, mut damage_events: EventWriter<DamageEvent>, ) { // Calculate and apply damage from all sources // ... // For creatures for (target, damage_amount) in creature_damage.iter() { if let Ok((_, _, mut health)) = creatures.get_mut(*target) { health.marked_damage += *damage_amount; // Generate damage event damage_events.send(DamageEvent { source: source_entity, target: *target, amount: *damage_amount, is_combat_damage: true, }); } } // For players and planeswalkers (similar logic) // ... } }
Special Damage Considerations
Deathtouch
Deathtouch modifies damage assignment rules:
- Any amount of damage from a source with deathtouch is considered lethal damage
- When assigning damage from an attacker with deathtouch to multiple blockers, only 1 damage needs to be assigned to each creature before moving to the next
Trample
Trample allows excess damage to be assigned to the player or planeswalker:
- The attacker must still assign lethal damage to all blockers
- Any remaining damage can be assigned to the defending player or planeswalker
- If all blocking creatures are removed from combat, all damage is assigned to the player or planeswalker
#![allow(unused)] fn main() { pub fn handle_trample_damage( combat_state: Res<CombatState>, creatures: Query<(Entity, &Creature, &Attack, Option<&Trample>)>, blockers: Query<(Entity, &Creature, &Health, &Blocking)>, mut player_damage: ResMut<HashMap<Entity, u32>>, ) { for (attacker, creature, attack, trample) in creatures.iter() { if trample.is_some() { // Calculate how much damage is needed for blockers // Assign excess to the player or planeswalker // ... } } } }
Protection and Prevention
Some effects modify how damage is dealt or received:
- Protection prevents damage from sources with specific characteristics
- Prevention effects can replace some or all damage that would be dealt
- These effects are applied during damage calculation, before damage is actually dealt
Damage Resolution
After combat damage is dealt, the game checks for state-based actions:
- Creatures with lethal damage are destroyed
- Players with 0 or less life lose the game
- Planeswalkers with 0 loyalty counters are put into their owner's graveyard
#![allow(unused)] fn main() { pub fn check_combat_damage_results( creatures: Query<(Entity, &Creature, &Health)>, players: Query<(Entity, &Player, &Life)>, planeswalkers: Query<(Entity, &Planeswalker, &Loyalty)>, mut destroy_events: EventWriter<DestroyPermanentEvent>, mut player_loss_events: EventWriter<PlayerLossEvent>, ) { // Check for destroyed creatures for (entity, _, health) in creatures.iter() { if health.marked_damage >= health.toughness { destroy_events.send(DestroyPermanentEvent { entity: entity, reason: DestructionReason::LethalDamage, }); } } // Check for player losses and planeswalker destruction // ... } }
Related Documentation
Abilities
This document outlines the different types of abilities in Magic: The Gathering as implemented in Rummage.
Types of Abilities
Magic: The Gathering has three main types of abilities:
- Activated Abilities: Abilities a player can activate by paying a cost
- Triggered Abilities: Abilities that trigger automatically when a specific event occurs
- Static Abilities: Abilities that modify the game rules or create continuous effects
Activated Abilities
Activated abilities are written in the format: "Cost: Effect." Examples include:
- "{T}: Add {G}." (A basic Forest's mana ability)
- "{2}{W}, {T}: Create a 1/1 white Soldier creature token."
- "{B}, Pay 2 life: Draw a card."
Implementation
In Rummage, activated abilities are implemented as follows:
#![allow(unused)] fn main() { pub struct ActivatedAbility { pub activation_cost: Cost, pub effect: AbilityEffect, pub timing_restriction: Option<TimingRestriction>, pub target_requirement: Option<TargetRequirement>, } pub enum Cost { Mana(ManaCost), Tap, PayLife(u32), SacrificeThis, SacrificePermanent(PermanentType), Discard(DiscardRequirement), Exile(ExileRequirement), Multiple(Vec<Cost>), // Other cost types... } }
Mana Abilities
A special subcategory of activated abilities, mana abilities:
- Produce mana
- Don't target
- Don't use the stack
- Resolve immediately
Triggered Abilities
Triggered abilities use the words "when," "whenever," or "at." Examples include:
- "When this creature enters the battlefield, draw a card."
- "Whenever a creature dies, gain 1 life."
- "At the beginning of your upkeep, draw a card."
Implementation
In Rummage, triggered abilities are implemented with:
#![allow(unused)] fn main() { pub struct TriggeredAbility { pub trigger_condition: TriggerCondition, pub effect: AbilityEffect, pub target_requirement: Option<TargetRequirement>, } pub enum TriggerCondition { EntersBattlefield(FilterType), LeavesBattlefield(FilterType), CastSpell(FilterType), BeginningOfPhase(Phase), EndOfPhase(Phase), CreatureDies(FilterType), DamageDealt(DamageFilter), // Other trigger conditions... } }
Trigger Types
There are several types of triggers:
- State triggers: "When/Whenever [state] is true..."
- Event triggers: "When/Whenever [event] occurs..."
- Phase/step triggers: "At the beginning of [phase/step]..."
Static Abilities
Static abilities apply continuously and don't use the stack. Examples include:
- "Creatures you control get +1/+1."
- "Opponent's spells cost {1} more to cast."
- "You have hexproof."
Implementation
In Rummage, static abilities are implemented as follows:
#![allow(unused)] fn main() { pub struct StaticAbility { pub effect: StaticEffect, pub condition: Option<Condition>, } pub enum StaticEffect { ModifyPowerToughness(PTModification), ModifyCost(CostModification), GrantKeyword(KeywordGrant), PreventActions(ActionPrevention), ReplaceEffect(ReplacementEffect), // Other static effects... } }
Layering System
Static abilities that modify characteristics are applied in a specific order defined by the "layer system":
- Copy effects
- Control-changing effects
- Text-changing effects
- Type-changing effects
- Color-changing effects
- Ability-adding/removing effects
- Power/toughness changing effects
Keyword Abilities
Keyword abilities are shorthand for common abilities:
- Flying: "This creature can only be blocked by creatures with flying or reach."
- Haste: "This creature can attack and use {T} abilities as soon as it comes under your control."
- Deathtouch: "Any amount of damage this deals to a creature is enough to destroy it."
- Trample: "This creature can deal excess combat damage to the player or planeswalker it's attacking."
Implementation
In Rummage, keyword abilities are implemented as:
#![allow(unused)] fn main() { pub enum KeywordAbility { Flying, Haste, Vigilance, Trample, FirstStrike, DoubleStrike, Deathtouch, Lifelink, Reach, Hexproof, Indestructible, // Other keywords... } }
Loyalty Abilities
Planeswalkers have loyalty abilities, a special type of activated ability:
- "+1: Draw a card."
- "-2: Create a 3/3 Beast creature token."
- "-8: You get an emblem with 'Creatures you control get +2/+2.'"
Implementation
Loyalty abilities are implemented as:
#![allow(unused)] fn main() { pub struct LoyaltyAbility { pub cost: i32, // Positive or negative loyalty change pub effect: AbilityEffect, pub target_requirement: Option<TargetRequirement>, } }
Implementation Status
The ability implementation status is:
- ✅ Basic activated abilities
- ✅ Mana abilities
- ✅ Common keyword abilities
- 🔄 Complex triggered abilities
- 🔄 Replacement effects
- 🔄 Layering system
- ✅ Loyalty abilities
Related Documentation
- Targeting: How abilities target
- Stack: How abilities use the stack
- Mana and Costs: How ability costs work
Casting Spells
This document outlines the rules for casting spells in Magic: The Gathering as implemented in Rummage.
Spell Casting Process
Casting a spell in Magic: The Gathering follows a specific sequence of steps:
- Announce the Spell: The player announces they are casting a spell and places it on the stack.
- Choose Modes: If the spell is modal, the player chooses which mode(s) to use.
- Choose Targets: If the spell requires targets, the player chooses legal targets.
- Choose Division of Effects: For spells that divide their effect, the player chooses how to divide it.
- Choose how to pay X: For spells with X in their cost, the player chooses the value of X.
- Determine Total Cost: Calculate the total cost, including additional or alternative costs.
- Activate Mana Abilities: The player activates mana abilities to generate mana.
- Pay Costs: The player pays all costs in any order.
After these steps, the spell is considered cast and is placed on the stack. It will resolve when all players pass priority in succession.
Implementation Details
In Rummage, spell casting is implemented using a state machine approach that guides the player through each step:
#![allow(unused)] fn main() { pub enum SpellCastingState { NotCasting, AnnouncingSpell, ChoosingModes, ChoosingTargets, DividingEffects, ChoosingX, CalculatingCost, GeneratingMana, PayingCosts, Completed, } pub struct SpellCasting { pub state: SpellCastingState, pub spell_entity: Option<Entity>, pub casting_player: Entity, pub modes_chosen: Vec<u32>, pub targets_chosen: Vec<TargetInfo>, pub divisions: Vec<u32>, pub x_value: u32, pub total_cost: ManaCost, pub paid_mana: Vec<ManaPayment>, } }
Types of Spells
Magic: The Gathering has several types of spells:
Permanent Spells
These become permanents on the battlefield when they resolve:
- Creature spells
- Artifact spells
- Enchantment spells
- Planeswalker spells
- Land cards (technically not spells)
Non-Permanent Spells
These go to the graveyard when they resolve:
- Instant spells
- Sorcery spells
Timing Restrictions
Different spell types have different timing restrictions:
- Instant: Can be cast at any time when a player has priority
- Sorcery: Can only be cast during a player's main phase when the stack is empty and they have priority
- Other spell types: Generally follow sorcery timing, with exceptions based on abilities
Special Cases
Split Cards
Split cards have two halves, and the player chooses which half to cast or may cast both halves with Fuse.
Modal Spells
Modal spells allow the player to choose from multiple effects, with the number of choices often specified on the card.
Spells with X in the Cost
The player chooses a value for X, which affects both the spell's cost and its effect.
Alternative Costs
Some spells offer alternative ways to cast them, such as:
- Flashback
- Overload
- Foretell
- Adventure
UI Integration
The spell casting process integrates with the UI through:
- Card Selection: Players select a card to begin casting
- Target Selection: Visual interface for selecting targets
- Cost Payment: Interface for choosing which mana to pay
- Mode Selection: Interface for choosing modes for modal spells
For more on the UI implementation, see Card Selection.
Implementation Status
The spell casting implementation is:
- ✅ Basic spell casting
- ✅ Cost calculation
- ✅ Target selection
- ✅ Mana payment
- 🔄 Alternative costs
- 🔄 Complex modal spells
- ✅ X spells
Related Documentation
- Stack: How spells interact with the stack
- Mana and Costs: Details on mana costs and payment
- Targeting: Rules for selecting targets
ECS Implementation of MTG Rules
This document explains how Magic: The Gathering rules are implemented using Bevy's Entity Component System (ECS) architecture in Rummage.
Table of Contents
- Introduction
- Entity Representations
- Component Design
- System Organization
- Event-Driven Mechanics
- Example Implementations
Introduction
Bevy's Entity Component System (ECS) architecture provides an ideal foundation for implementing Magic: The Gathering's complex rule system. This architecture offers several key advantages:
- Data-Logic Separation: Game data (components) remains separate from game logic (systems)
- Parallelism: Game mechanics can process in parallel where possible
- Composition: Entities are composed of reusable components rather than inheritance hierarchies
- Extensibility: New functionality can be added without modifying existing code
These benefits directly address the challenges of implementing MTG's intricate, interconnected rule system in a maintainable, testable, and performant way.
Entity Representations
In Rummage, we model the core MTG game elements as entities with specific components:
Cards
Cards are represented as entities with components describing their characteristics:
#![allow(unused)] fn main() { // Core card identity #[derive(Component)] struct Card { id: String, oracle_id: Uuid, } // Name component (separate for query efficiency) #[derive(Component)] struct CardName(String); // Card type component #[derive(Component)] enum CardType { Creature, Instant, Sorcery, Artifact, Enchantment, Land, Planeswalker, } // Mana cost component #[derive(Component)] struct ManaCost { white: u8, blue: u8, black: u8, red: u8, green: u8, colorless: u8, generic: u8, } // Creature-specific components #[derive(Component)] struct Power(i32); #[derive(Component)] struct Toughness(i32); }
Players
Players are entities with components tracking game state:
#![allow(unused)] fn main() { // Core player identity #[derive(Component)] struct Player { id: Uuid, name: String, } // Life total component #[derive(Component)] struct Life(i32); // Mana pool component #[derive(Component)] struct ManaPool { white: u8, blue: u8, black: u8, red: u8, green: u8, colorless: u8, } }
Zones
Game zones are implemented as entities with specialized components:
#![allow(unused)] fn main() { // Zone type identifier #[derive(Component)] enum ZoneType { Battlefield, Hand, Library, Graveyard, Stack, Exile, Command, } // Container for entities in a zone #[derive(Component)] struct ZoneContents { entities: Vec<Entity>, } // Zone ownership #[derive(Component)] struct BelongsToPlayer(Entity); }
Component Design
Our component design adheres to these core principles:
- Single Responsibility: Each component represents one specific aspect of a game entity
- Data-Oriented: Components store data only, not behavior
- Minimalist: Components include only necessary data to minimize memory usage
- Composable: Complex entities are built by combining simple components
This approach enables flexible entity composition. For example, a creature permanent on the battlefield might have:
#![allow(unused)] fn main() { // A creature permanent's components Entity { Card { id: "c4a81753", oracle_id: "..." }, CardName("Llanowar Elves"), CardType::Creature, Power(1), Toughness(1), InZone(ZoneType::Battlefield), UntappedState, TapForManaAbility { color: Green }, CreatureType(vec!["Elf", "Druid"]), ControlledBy(player_entity), // Additional components for abilities, counters, etc. } }
System Organization
Systems implement game rules and mechanics, organized into logical categories:
Turn Structure Systems
Systems that handle the progression of game turns:
#![allow(unused)] fn main() { // Beginning of turn system fn begin_turn_system( mut turn_state: ResMut<TurnState>, mut events: EventWriter<BeginTurnEvent>, query: Query<Entity, With<ActivePlayer>>, ) { if let Ok(active_player) = query.get_single() { events.send(BeginTurnEvent { player: active_player }); turn_state.phase = Phase::Beginning; turn_state.step = Step::Untap; } } // Untap step system fn untap_step_system( turn_state: Res<TurnState>, active_player: Query<Entity, With<ActivePlayer>>, mut permanents: Query<(Entity, &ControlledBy, &mut UntappedState)>, ) { // Skip if not in untap step if turn_state.phase != Phase::Beginning || turn_state.step != Step::Untap { return; } // Get active player let active_player = match active_player.get_single() { Ok(player) => player, Err(_) => return, }; // Untap permanents controlled by active player for (_, controlled_by, mut untapped) in &mut permanents { if controlled_by.0 == active_player { *untapped = UntappedState::Untapped; } } } }
State-Based Actions
Systems that check and apply state-based effects:
#![allow(unused)] fn main() { // System for creatures with 0 or less toughness fn check_creature_death( mut commands: Commands, creatures: Query<(Entity, &Toughness, &InZone), With<CardType::Creature>>, mut zone_events: EventWriter<ZoneChangeEvent>, ) { for (entity, toughness, zone) in &creatures { if toughness.0 <= 0 && zone.0 == ZoneType::Battlefield { // Move creature to graveyard zone_events.send(ZoneChangeEvent { entity, from: ZoneType::Battlefield, to: ZoneType::Graveyard, cause: ZoneChangeCause::StateBased, }); } } } // System for players with 0 or less life fn check_player_loss( players: Query<(Entity, &Life, &Player)>, mut game_events: EventWriter<GameEvent>, ) { for (entity, life, player) in &players { if life.0 <= 0 { game_events.send(GameEvent::PlayerLost { player: entity, reason: LossReason::ZeroLife, }); } } } }
Spell Resolution
Systems for spell casting and resolution:
#![allow(unused)] fn main() { // Adding spells to the stack fn cast_spell_system( mut commands: Commands, mut cast_events: EventReader<CastSpellEvent>, mut stack: ResMut<Stack>, ) { for event in cast_events.iter() { // Create stack object entity let stack_object = commands.spawn(( StackObject, SourceCard(event.card), ControlledBy(event.controller), event.targets.clone(), // Additional stack object components )).id(); // Add to stack stack.objects.push(stack_object); } } // Resolving the top object on the stack fn resolve_top_of_stack( mut commands: Commands, mut stack: ResMut<Stack>, objects: Query<(Entity, &StackObject, &SourceCard, &ControlledBy)>, cards: Query<&CardType>, mut resolution_events: EventWriter<StackResolutionEvent>, ) { if let Some(top_object) = stack.objects.pop() { if let Ok((entity, _, source_card, controller)) = objects.get(top_object) { // Determine appropriate resolution based on card type if let Ok(card_type) = cards.get(source_card.0) { resolution_events.send(StackResolutionEvent { stack_object: entity, source: source_card.0, controller: controller.0, card_type: card_type.clone(), }); } } } } }
Event-Driven Mechanics
MTG's reactive mechanics are implemented using Bevy's event system:
Game Events
#![allow(unused)] fn main() { // Card draw event #[derive(Event)] struct DrawCardEvent { player: Entity, amount: usize, } // Damage event #[derive(Event)] struct DamageEvent { source: Entity, target: Entity, amount: u32, is_combat_damage: bool, } // Zone change event #[derive(Event)] struct ZoneChangeEvent { entity: Entity, from: ZoneType, to: ZoneType, cause: ZoneChangeCause, } }
Event Handlers
Systems that respond to these events:
#![allow(unused)] fn main() { // Handle card drawing fn handle_draw_event( mut events: EventReader<DrawCardEvent>, player_hands: Query<(Entity, &ZoneType, &BelongsToPlayer)>, player_libraries: Query<(Entity, &ZoneType, &BelongsToPlayer, &ZoneContents)>, mut commands: Commands, mut zone_events: EventWriter<ZoneChangeEvent>, ) { for event in events.iter() { // Find player's library and hand // Move top N cards from library to hand // ... } } // Handle damage fn handle_damage_event( mut events: EventReader<DamageEvent>, mut players: Query<(Entity, &mut Life)>, mut creatures: Query<(Entity, &mut Toughness)>, mut damage_taken: Query<&mut DamageTaken>, ) { for event in events.iter() { // Apply damage based on target type // ... } } }
Example Implementations
This section provides complete examples of how complex MTG mechanics are implemented using the ECS architecture.
Creature Combat Example
Here's how the combat system is implemented:
#![allow(unused)] fn main() { // Declare attackers fn declare_attackers_system( mut commands: Commands, turn_state: Res<TurnState>, mut attack_declarations: EventReader<DeclareAttackerEvent>, creatures: Query<(Entity, &ControlledBy, &UntappedState), With<CardType::Creature>>, active_player: Query<Entity, With<ActivePlayer>>, mut phase_events: EventWriter<PhaseChangeEvent>, ) { // Validate phase if turn_state.phase != Phase::Combat || turn_state.step != Step::DeclareAttackers { return; } // Process attack declarations for event in attack_declarations.iter() { if let Ok((entity, controller, untapped_state)) = creatures.get(event.creature) { // Check if creature is controlled by active player and untapped if let Ok(active) = active_player.get_single() { if controller.0 == active && matches!(untapped_state, UntappedState::Untapped) { // Mark as attacking commands.entity(entity).insert(Attacking { defending_player: event.target, // Additional attack data }); // Tap the creature commands.entity(entity).insert(UntappedState::Tapped); } } } } // When all declarations are done, advance to next step phase_events.send(PhaseChangeEvent { new_phase: Phase::Combat, new_step: Step::DeclareBlockers, }); } }
Card Drawing and Libraries
Implementation of card drawing:
#![allow(unused)] fn main() { // Draw card system fn draw_card_system( mut events: EventReader<DrawCardEvent>, players: Query<&Player>, libraries: Query<(Entity, &ZoneContents, &ZoneType, &BelongsToPlayer)>, hands: Query<(Entity, &ZoneType, &BelongsToPlayer)>, mut zone_events: EventWriter<ZoneChangeEvent>, mut game_events: EventWriter<GameEvent>, ) { for event in events.iter() { // Find player's library let player_library = libraries.iter() .find(|(_, _, zone_type, belongs_to)| { **zone_type == ZoneType::Library && belongs_to.0 == event.player }); if let Some((library_entity, contents, _, _)) = player_library { // Check if player can draw enough cards if contents.entities.len() < event.amount { // Not enough cards - player loses game_events.send(GameEvent::PlayerLost { player: event.player, reason: LossReason::EmptyLibrary, }); continue; } // Find player's hand let player_hand = hands.iter() .find(|(_, zone_type, belongs_to)| { **zone_type == ZoneType::Hand && belongs_to.0 == event.player }); if let Some((hand_entity, _, _)) = player_hand { // Move cards from library to hand for _ in 0..event.amount { if let Some(&card) = contents.entities.last() { zone_events.send(ZoneChangeEvent { entity: card, from: ZoneType::Library, to: ZoneType::Hand, cause: ZoneChangeCause::Draw, }); } } } } } } }
Connecting ECS to MTG Rules
This ECS implementation maps directly to the MTG Comprehensive Rules:
- Entities: Correspond to objects in the MTG rules (cards, permanents, players)
- Components: Represent characteristics and states defined in the rules
- Systems: Implement rules procedures and state transitions
- Events: Model the discrete game events that trigger rule applications
This mapping ensures that the implementation accurately reflects the official rules while leveraging the performance and flexibility benefits of the ECS architecture.
For more specific implementations, see:
Commander Format
This section documents the implementation of the Commander (EDH) format in Rummage, a multiplayer format that emphasizes social gameplay, unique deck construction constraints, and strategic depth.
Note: For a high-level overview of the Commander format rules without implementation details, see the Commander-Specific Rules Reference.
Format Overview
Commander (formerly known as Elder Dragon Highlander or EDH) is a sanctioned Magic: The Gathering format with these defining characteristics:
Feature | Description | Implementation |
---|---|---|
Deck Construction | 100-card singleton decks (no duplicates except basic lands) | Deck validation systems |
Commander | A legendary creature that leads your deck | Command zone and casting mechanics |
Color Identity | Deck colors must match commander's color identity | Deck validation and color checking |
Life Total | 40 starting life (vs. standard 20) | Modified game initialization |
Commander Damage | 21 combat damage from a single commander causes loss | Per-commander damage tracking |
Multiplayer Focus | Designed for 3-6 players | Turn ordering and multiplayer mechanics |
Documentation Structure
The documentation is organized into the following sections:
- Overview - High-level overview of the Commander format and implementation approach
- Game Mechanics - Core game state and mechanics implementation
- Game State Management
- State-Based Actions
- Random Mechanics (coin flips, dice rolls)
- Player Mechanics - Player-specific rules and interactions
- Life Total Management
- Commander Tax
- Color Identity
- Game Zones - Implementation of game zones, especially the Command Zone
- Command Zone
- Zone Transfers
- Zone-specific Rules
- Turns and Phases - Turn structure and phase management
- Turn Order
- Phase Management
- Multiplayer Considerations
- Stack and Priority - Stack implementation and priority system
- Priority Passing
- Stack Resolution
- Special Timing Rules
- Combat - Combat mechanics including commander damage
- Combat Phases
- Commander Damage Tracking
- Multiplayer Combat
- Special Rules - Format-specific rules and unique mechanics
- Partner Commanders
- Commander Death Triggers
- Commander-specific Abilities
- Core Integration - How Commander extends MTG core rules
Key Mechanics Implementation
Command Zone
The Command Zone serves as the foundation of the format:
#![allow(unused)] fn main() { // Command Zone implementation #[derive(Component)] struct CommandZone { owner: Entity, contents: Vec<Entity>, } // Commander component #[derive(Component)] struct Commander { owner: Entity, cast_count: u32, } }
Key implementations:
- Commanders start in the Command Zone
- Zone transfer options when commanders change zones
- Commander Tax calculation (
2
additional mana per previous cast)
Commander Damage Tracking
#![allow(unused)] fn main() { // Tracking damage from each commander #[derive(Component)] struct CommanderDamageTracker { // Maps commander entities to damage received damage_taken: HashMap<Entity, u32>, } // System that checks for commander damage loss condition fn check_commander_damage_loss( tracker: Query<(Entity, &CommanderDamageTracker, &Player)>, mut game_events: EventWriter<GameEvent>, ) { for (entity, tracker, player) in &tracker { for (_, damage) in tracker.damage_taken.iter() { if *damage >= 21 { game_events.send(GameEvent::PlayerLost { player: entity, reason: LossReason::CommanderDamage, }); break; } } } } }
Key Commander Rules
The following key Commander rules are implemented in our engine:
Rule | Description | Implementation Status |
---|---|---|
Singleton | Only one copy of each card allowed (except basic lands) | ✅ |
Commander | Legendary creature in command zone | ✅ |
Color Identity | Cards must match commander's color identity | ✅ |
Command Zone | Special zone for commanders | ✅ |
Commander Tax | Additional {2} cost each time cast from command zone | ✅ |
Commander Damage | 21 combat damage from a single commander | ✅ |
Starting Life | 40 life points | ✅ |
Commander Replacement | Optional replacement to command zone | ✅ |
Partner Commanders | Special commanders that can be paired | 🔄 |
Commander Ninjutsu | Special ability for certain commanders | ⚠️ |
Commander-specific Cards | Cards that reference the command zone or commanders | 🔄 |
Technical Implementation
The Commander format is implemented as a Bevy plugin that extends the core MTG rules:
#![allow(unused)] fn main() { pub struct CommanderPlugin; impl Plugin for CommanderPlugin { fn build(&self, app: &mut App) { app // Commander components .register_type::<Commander>() .register_type::<CommanderDamage>() .register_type::<ColorIdentity>() // Commander resources .init_resource::<CommanderConfig>() // Commander systems .add_systems(Startup, commander_game_setup) .add_systems( PreUpdate, (check_commander_zone_transfers, validate_color_identity) ) .add_systems( Update, (track_commander_damage, apply_commander_tax) ); } } }
Testing Strategy
Commander testing focuses on these key areas:
- Rule Compliance: Verifying all Commander-specific rules
- Integration Testing: Testing interaction with core MTG systems
- Multiplayer Scenarios: Validating complex multiplayer situations
- Edge Cases: Partner commanders, commander ninjutsu, and other special mechanics
Each section includes detailed test cases to validate the correct implementation of Commander rules. Our testing approach ensures:
- Full coverage of Commander-specific rules
- Edge case handling for unique interactions
- Performance validation for multiplayer scenarios
- Verification of correct rule application in complex board states
For detailed testing approaches, see the Commander Testing Guide.
Implementation Status
Feature | Status | Notes |
---|---|---|
Command Zone | ✅ Implemented | Complete zone mechanics |
Commander Casting | ✅ Implemented | With tax calculation |
Zone Transfers | ✅ Implemented | With player choice |
Commander Damage | ✅ Implemented | With per-commander tracking |
Color Identity | ✅ Implemented | Deck validation |
Partner Commanders | 🔄 In Progress | Basic functionality working |
Multiplayer Politics | ⚠️ Planned | Design in progress |
For more information on the official Commander rules, refer to the Commander Format Rules.
Commander Overview
This section provides a high-level overview of the Commander format implementation in our game engine.
Contents
- Format Rules - Core rules specific to the Commander format
- Architecture - Overview of the implementation architecture
- Implementation Approach - General approach to implementing the Commander format
Format Summary
The Commander format is a multiplayer variant of Magic: The Gathering with special rules including:
- Each player begins the game with a designated legendary creature as their "commander"
- Players start with 40 life
- A player can cast their commander from the command zone for its mana cost, plus an additional 2 mana for each previous time it's been cast
- If a commander would be put into a library, hand, graveyard or exile, its owner may choose to move it to the command zone
- A player who has been dealt 21 or more combat damage by the same commander loses the game
Implementation Principles
Our implementation of the Commander format follows these key principles:
- Rule Accuracy - Faithful implementation of the official Commander rules
- Performance - Optimized for multiplayer games with complex board states
- Extensibility - Designed to easily incorporate new Commander-specific cards and mechanics
- Testability - Comprehensive test suite for validating format-specific rules
See the Implementation Approach document for more detailed information on how these principles are applied in our codebase.
Related Sections
- Game Mechanics - For detailed mechanics implementation
- Combat - For Commander damage tracking and combat interactions
- Game Zones - For Command Zone implementation details
Commander Format History
Commander (formerly known as Elder Dragon Highlander or EDH) has an interesting history that evolved from a casual player-created format to one of Magic: The Gathering's most popular formats today.
Origins
The Commander format was created in the late 1990s by judges in Alaska who were looking for a new way to play between tournament rounds. The format was originally called "Elder Dragon Highlander" (EDH) because:
- Players were required to use one of the five Elder Dragons from Legends as their general (later called commander)
- The "Highlander" part referenced the 1986 film and its tagline "There can be only one," signifying the singleton nature of the format
Evolution of the Format
Time Period | Development |
---|---|
Late 1990s | Format created by Alaska judges |
Early 2000s | Spread through judge community and casual players |
2005-2010 | Growing online presence and popularity |
2011 | Wizards of the Coast officially recognized the format and released the first Commander preconstructed decks |
2011+ | Annual commander products and growing mainstream popularity |
Present | One of Magic's most popular formats with dedicated products |
Rules Committee
The Commander format is officially governed by the Commander Rules Committee (RC), an independent group that:
- Maintains the format's rules and banned list
- Makes decisions about format changes
- Promotes the philosophy of Commander as a social, multiplayer format
The Rules Committee maintains that Commander should remain primarily a social format that emphasizes fun, memorable gameplay over competition.
Implementation in Rummage
In Rummage, we've faithfully implemented the Commander format according to the official rules while maintaining the flexibility to adapt to future changes. Our implementation:
- Follows the current Commander rules
- Includes historical context in design decisions
- Allows for easy adaptation to rule changes
References
For more information on the official Commander rules and history:
Key Principles of Commander
The Commander format is built upon core design principles that guide both the rules and the social contract of the format. These principles inform our implementation in Rummage.
Social Focus
Commander was designed as a multiplayer, social format where:
- Fun and memorable gameplay takes precedence over competitive optimization
- Politics and table talk are encouraged
- The journey of the game is as important as the outcome
- Players are encouraged to build decks that express their identity and creativity
Format Pillars
Variance and Uniqueness
The singleton requirement (only one copy of each card except basic lands) ensures:
- Every game plays differently
- Decks feel unique and personalized
- Players must be adaptable to changing game states
- Card evaluation differs from other formats
Commander Identity
The commander serves as:
- The centerpiece of the deck's strategy
- A constant presence throughout the game
- A representation of the player's identity
- A build-around card that shapes deckbuilding decisions
Multiplayer Dynamics
Commander embraces multiplayer game design through:
- Balanced starting life totals (40 life)
- Political elements like deal-making and temporary alliances
- Mechanics that encourage table-wide interactions
- Rules that prevent early eliminations when possible
Design Philosophy
The Commander Rules Committee articulates the following design philosophy:
Commander is designed to promote social games of magic. It suits players who want to:
- Cultivate a casual atmosphere where everyone can have fun, not just the winner
- Build personalized decks that showcase interesting cards and strategies
- Create game states full of twists and turns that lead to epic plays and memorable moments
Implementation in Rummage
Our implementation of Commander in Rummage emphasizes:
- Faithful Rules Implementation: Accurate implementation of the official rules
- Social Features: Support for politics, deals, and multiplayer interactions
- Player Expression: Flexible deck building and commander options
- Game Variance: Random elements and complexity that lead to unique game states
- Accessibility: Clear visualizations of complex board states and commander-specific rules
Rules vs. Social Contract
We recognize that Commander has both written rules and an unwritten social contract. While we can fully implement the rules, the social contract is up to players. We provide:
- Tools for communication between players
- Flexibility in game settings
- Support for house rules when possible
- Documentation that explains both rules and social expectations
Commander Format Rules
Overview
Commander (formerly known as Elder Dragon Highlander or EDH) is a multiplayer variant of Magic: The Gathering with special rules defined in section 903 of the Magic: The Gathering Comprehensive Rules.
Core Rules
-
Deck Construction (rule 903.5)
- 100-card singleton format (only one copy of each card except for basic lands)
- Deck can only include cards that match the color identity of the commander
- Color identity includes all colored mana symbols in the card's cost, rules text, and color indicator
-
Commander Card (rule 903.3)
- Each player designates a legendary creature as their "commander"
- The commander is placed in the command zone at the start of the game
- Partner commanders and Background mechanics allow for special exceptions
-
Life Total (rule 903.7)
- Players start with 40 life (instead of the standard 20)
-
Command Zone Mechanics (rules 903.8, 903.9)
- Commanders start in the command zone
- Commanders can be cast from the command zone for their mana cost, plus an additional {2} for each previous time they've been cast from the command zone ("commander tax")
- If a commander would be put into a library, hand, graveyard, or exile, its owner may choose to move it to the command zone instead
-
Commander Damage (rule 903.10)
- A player who has been dealt 21 or more combat damage by the same commander loses the game
-
Banned Cards (rule 903.11)
- The Commander format has its own banned list maintained by the Commander Rules Committee
Additional Rules
-
Multiplayer Rules
- Standard multiplayer rules apply (first player doesn't draw, etc.)
- Free-for-all, attack-anyone format unless playing with teams
-
Color Identity
- A card's color identity includes all mana symbols in its cost and rules text
- Cards in a player's deck must only contain mana symbols that appear in their commander's color identity
- Basic land types implicitly have the corresponding mana symbol in their identity
-
Replacement Commander
- In casual play, if a player's commander would be put into a library, hand, graveyard or exile from anywhere, that player may put it into the command zone instead
These rules create a unique gameplay experience that emphasizes multiplayer interaction, powerful cards, and distinctive deck construction constraints.
Implementation Approach
Core Principles
Our implementation of the Commander format follows these key principles:
- Rules Accuracy - Faithfully implement the official Magic: The Gathering Comprehensive Rules for Commander (section 903)
- Modularity - Clear separation of concerns between different aspects of the game engine
- Testability - Comprehensive testing for complex rule interactions
- Performance - Optimized for multiplayer games with up to 13 players
- Extensibility - Easily accommodate new Commander variants and house rules
Technology Stack
The implementation uses the following technologies:
- Bevy ECS - For game state management and systems organization
- Rust - For type safety and performance
- Event-driven architecture - For game actions and triggers
- Automated testing - For rules validation and edge cases
Key Implementation Techniques
-
Component-Based Design
#![allow(unused)] fn main() { // Components for different aspects of game entities #[derive(Component)] pub struct Commander { pub cast_count: u32, } #[derive(Component)] pub struct CommanderDamage { // Maps commander entity ID to damage received pub damage_received: HashMap<Entity, u32>, } }
-
Resource-Based Game State
#![allow(unused)] fn main() { #[derive(Resource)] pub struct CommanderGameState { pub active_player_index: usize, pub player_order: Vec<Entity>, pub current_phase: Phase, // More fields } }
-
Event-Driven Actions
#![allow(unused)] fn main() { #[derive(Event)] pub struct CommanderCastEvent { pub commander: Entity, pub player: Entity, pub from_zone: Zone, } }
-
Systems for Game Logic
#![allow(unused)] fn main() { fn commander_damage_system( mut commands: Commands, game_state: Res<CommanderGameState>, mut damage_events: EventReader<CommanderDamageEvent>, mut players: Query<(Entity, &mut CommanderDamage)>, ) { // Logic implementation } }
Development Approach
-
Incremental Implementation
- Start with core game state management
- Layer in Commander-specific rules
- Add multiplayer functionality
- Implement special cases and edge conditions
-
Testing Strategy
- Unit tests for individual components
- Integration tests for system interactions
- Scenario-based tests for complex rule interactions
- Performance tests for multiplayer scenarios
-
Documentation
- Comprehensive documentation of implementation details
- Cross-referencing with official rules
- Examples of complex interactions
This structured approach ensures a robust implementation of the Commander format that accurately reflects the official rules while maintaining performance and extensibility.
Architecture Overview
System Architecture
The Commander rules engine is organized into several interconnected modules that work together to provide a complete implementation of the Commander format:
-
Game State Management
- Core state tracking and game flow coordination
- Central resource for game metadata and state
- Integration point for all other systems
-
Player Management
- Player data structures and tracking
- Life total management (starting at 40)
- Commander damage tracking
-
Command Zone Management
- Special zone for Commander cards
- Commander casting and tax implementation
- Zone transition rules
-
Turn Structure & Phases
- Complete turn sequence implementation
- Priority passing in multiplayer context
- Phase-based effects and triggers
-
Combat System
- Multiplayer combat implementation
- Commander damage tracking
- Attack declaration and blocking in multiplayer
-
Priority & Stack
- Priority passing algorithms for multiplayer
- Stack implementation for spells and abilities
- Resolution mechanics in complex scenarios
-
State-Based Actions
- Game state checks including commander damage threshold (21)
- Format-specific state checks
- Automatic game actions
-
Special Commander Rules
- Color identity validation
- Commander zone movement replacement effects
- Partner and Background mechanics
-
Multiplayer Politics
- Voting mechanics
- Deal-making systems
- Multiplayer-specific card effects
Integration with Bevy ECS
The Commander implementation leverages Bevy ECS (Entity Component System) for game state management:
#![allow(unused)] fn main() { // Game state as a Bevy resource #[derive(Resource)] pub struct CommanderGameState { // Game state fields } // Player as an entity with components #[derive(Component)] pub struct CommanderPlayer { // Player data } // Systems for game logic fn process_commander_damage( mut game_state: ResMut<CommanderGameState>, query: Query<(&CommanderPlayer, &CommanderDamage)>, ) { // Implementation } }
The architecture allows for clean separation of concerns while maintaining the complex relationships between different aspects of the Commander format.
Game Mechanics
This section covers the core game mechanics specific to the Commander format implementation.
Contents
- Game State - Core state tracking and management
- State-Based Actions - Commander-specific state checks and automatic game actions
- Triggered Abilities - Commander-specific triggered abilities and effects
- Random Elements - Testing random elements like coin flips and dice rolls
- Testing Guide - Special considerations for testing Commander mechanics
The game mechanics section defines how the Commander format's unique rules are implemented and enforced within our game engine, including:
- Commander-specific state tracking
- Unique state-based actions like commander damage checks
- Special triggered abilities for command zone interactions
- Format-specific mechanics like Lieutenant and Partner triggers
- Handling multiple player elimination and game completion
- Randomized mechanics like coin flips and dice rolls
These mechanics build on the foundational Magic: The Gathering rules while incorporating the unique aspects of the Commander format. The implementation is designed to be both accurate to the official rules and performant for multiplayer gameplay.
Game Engine Testing Guide
Overview
This guide outlines the comprehensive testing approach for the Rummage Magic: The Gathering Commander game engine. Testing a complex game engine requires multiple layers of validation to ensure rules are correctly implemented and interactions work as expected.
Testing Philosophies
The Rummage testing strategy follows these core philosophies:
- Rules-First Testing: Test cases are derived directly from the MTG Comprehensive Rules
- Isolation and Integration: Test components in isolation before testing their interactions
- Edge Case Coverage: Explicitly test corner cases and unusual card interactions
- Performance Validation: Ensure the engine performs well under various game conditions
- Reproducibility: All tests should be deterministic and repeatable
- Visual Consistency: Ensure consistent rendering across platforms and game states
Testing Layers
The game engine testing is structured in layers:
1. Unit Tests
Unit tests validate individual components in isolation. Each module should have comprehensive unit tests covering:
- Basic functionality
- Edge cases
- Error handling
- Public interface contracts
Example for the stack system:
#![allow(unused)] fn main() { #[test] fn test_stack_push_and_resolve() { let mut app = App::new(); // Setup minimal test environment app.add_plugins(MinimalPlugins) .add_plugin(StackPlugin); // Add a spell to the stack let spell_entity = app.world.spawn_empty().id(); app.world.send_event(StackPushEvent { entity: spell_entity, source: None, }); // Run systems app.update(); // Verify stack state let stack = app.world.resource::<Stack>(); assert_eq!(stack.items.len(), 1); // Resolve the top item app.world.send_event(StackResolveTopEvent); app.update(); // Verify stack is empty let stack = app.world.resource::<Stack>(); assert_eq!(stack.items.len(), 0); } }
2. Integration Tests
Integration tests verify the interaction between multiple components. Key integration scenarios include:
- Turn structure and phase progression
- Combat resolution
- Stack interaction with permanents
- Zone transitions
Example integration test:
#![allow(unused)] fn main() { #[test] fn test_spell_cast_and_resolve_integration() { let mut app = App::new(); // Setup more complete environment app.add_plugins(GameEngineTestPlugins) .add_systems(Startup, setup_test_game); // Create test player and card let (player, card) = setup_test_player_with_card(&mut app); // Cast the spell app.world.send_event(CastSpellEvent { player, card, targets: Vec::new(), mode: CastMode::Normal, }); // Run systems to process the cast app.update(); // Verify card moved to stack let stack = app.world.resource::<Stack>(); assert!(stack.contains(card)); // Resolve the stack resolve_stack_completely(&mut app); // Verify expected outcome based on card type let card_type = app.world.get::<CardType>(card).unwrap(); match *card_type { CardType::Creature => { // Verify creature entered battlefield let battlefield = app.world.resource::<Battlefield>(); assert!(battlefield.contains(card)); }, CardType::Sorcery => { // Verify sorcery went to graveyard let graveyard = get_player_graveyard(&app, player); assert!(graveyard.contains(card)); }, // Handle other card types _ => {} } } }
3. End-to-End Tests
End-to-end tests simulate complete game scenarios to validate the engine as a whole:
#![allow(unused)] fn main() { #[test] fn test_complete_game_scenario() { let mut app = App::new(); // Setup full game environment app.add_plugins(FullGameTestPlugins) .add_systems(Startup, setup_full_test_game); // Load predefined scenario let scenario = TestScenario::load("scenarios/two_player_creature_combat.json"); scenario.apply_to_app(&mut app); // Run a fixed number of turns run_turns(&mut app, 3); // Verify expected game state let game_state = app.world.resource::<GameState>(); assert_eq!(game_state.active_player_index, 1); // Verify player life totals let players = app.world.query::<&Player>().iter(&app.world).collect::<Vec<_>>(); assert_eq!(players[0].life_total, 35); assert_eq!(players[1].life_total, 38); // Verify battlefield state let battlefield = app.world.resource::<Battlefield>(); assert_eq!(battlefield.creatures_for_player(players[0].entity).count(), 2); assert_eq!(battlefield.creatures_for_player(players[1].entity).count(), 1); } }
4. Visual Differential Testing
Visual differential testing ensures consistent rendering across platforms and updates:
#![allow(unused)] fn main() { #[test] fn test_card_rendering_consistency() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(VisualTestingPlugin) .add_systems(Startup, setup_card_rendering_test); // Configure test environment let card_states = [ "in_hand", "on_battlefield", "tapped", "with_counters" ]; for state in &card_states { // Configure card state setup_card_state(&mut app, state); app.update(); // Capture rendering if let Some(screenshot) = take_screenshot(&app) { // Compare with reference match load_reference_image(&format!("card_{}.png", state)) { Ok(reference) => { let result = compare_images(&screenshot, &reference); assert!( result.similarity_score > 0.99, "Card rendering for state '{}' differs from reference", state ); }, Err(_) => { // Generate reference if not exists let _ = save_reference_image(screenshot, &format!("card_{}.png", state)); } } } } } }
Test Data Management
Card Test Database
A specialized test card database simplifies testing of specific interactions:
#![allow(unused)] fn main() { // Access test cards by specific properties let board_wipe = test_cards::get_card("board_wipe"); let counter_spell = test_cards::get_card("counter_spell"); let indestructible_creature = test_cards::get_card("indestructible_creature"); // Test interaction test_interaction(board_wipe, indestructible_creature); }
Scenario Files
Predefined test scenarios enable reproducible complex game states:
{
"players": [
{
"name": "Player 1",
"life": 40,
"battlefield": ["test_cards/serra_angel", "test_cards/sol_ring"],
"hand": ["test_cards/counterspell", "test_cards/lightning_bolt"],
"graveyard": ["test_cards/llanowar_elves"]
},
{
"name": "Player 2",
"life": 36,
"battlefield": ["test_cards/goblin_guide", "test_cards/birds_of_paradise"],
"hand": ["test_cards/wrath_of_god"],
"graveyard": []
}
],
"turn": {
"active_player": 0,
"phase": "main1",
"priority_player": 0
}
}
Testing Particular Systems
Mana System Testing
The mana system requires specific testing for:
- Mana Production: Test ability to produce mana from various sources
- Mana Payment: Test payment for spells and abilities
- Mana Restrictions: Test "spend only on X" restrictions
- Color Identity: Test commander color identity rules
#![allow(unused)] fn main() { #[test] fn test_mana_restrictions() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(ManaPlugin); // Create a player with a mana pool let player = setup_test_player(&mut app); // Add mana with "spend this mana only on creature spells" restriction add_restricted_mana(&mut app, player, ManaColor::Green, 3, ManaRestriction::CreatureSpells); // Test allowed payment let creature_card = spawn_test_card(&mut app, "Grizzly Bears"); let result = try_pay_mana_cost(&mut app, player, creature_card); assert!(result.is_success); // Test restricted payment let noncreature_card = spawn_test_card(&mut app, "Giant Growth"); let result = try_pay_mana_cost(&mut app, player, noncreature_card); assert!(result.is_failure); assert_eq!(result.failure_reason, ManaPaymentFailure::RestrictionViolation); } }
Combat System Testing
Combat testing should validate:
- Attack Declaration: Rules about who can attack
- Blocker Declaration: Valid blocking assignments
- Combat Damage: Correct damage assignment and processing
- Combat Effects: Triggers that happen during combat
#![allow(unused)] fn main() { #[test] fn test_combat_damage_assignment() { let mut app = App::new(); app.add_plugins(GameEngineTestPlugins); // Setup attacker with 4/4 stats let attacker = spawn_test_creature(&mut app, 4, 4); // Setup two blockers: 2/2 and 1/1 let blocker1 = spawn_test_creature(&mut app, 2, 2); let blocker2 = spawn_test_creature(&mut app, 1, 1); // Declare attack declare_attacker(&mut app, attacker); // Declare blockers declare_blockers(&mut app, vec![blocker1, blocker2], attacker); // Assign damage: 2 to first blocker, 2 to second blocker assign_combat_damage(&mut app, attacker, vec![(blocker1, 2), (blocker2, 2)]); // Process damage process_combat_damage(&mut app); // Verify results assert!(is_creature_dead(&app, blocker1)); assert!(is_creature_dead(&app, blocker2)); assert!(!is_creature_dead(&app, attacker)); } }
Stack and Priority Testing
Testing stack interactions requires:
- Proper Sequencing: Items resolve in LIFO order
- Priority Passing: Correct priority assignment during resolution
- Interruption: Ability to respond to items on the stack
- Special Actions: Actions that don't use the stack
#![allow(unused)] fn main() { #[test] fn test_stack_priority_and_responses() { let mut app = App::new(); app.add_plugins(GameEngineTestPlugins); // Setup players let player1 = setup_test_player(&mut app); let player2 = setup_test_player(&mut app); // Setup cards let lightning_bolt = spawn_test_card(&mut app, "Lightning Bolt"); let counterspell = spawn_test_card(&mut app, "Counterspell"); // Give cards to players give_card_to_player(&mut app, lightning_bolt, player1); give_card_to_player(&mut app, counterspell, player2); // Player 1 casts Lightning Bolt cast_spell(&mut app, player1, lightning_bolt, Some(player2)); // Verify Lightning Bolt is on the stack let stack = app.world.resource::<Stack>(); assert_eq!(stack.items.len(), 1); // Player 2 responds with Counterspell cast_spell(&mut app, player2, counterspell, Some(lightning_bolt)); // Verify both spells are on the stack let stack = app.world.resource::<Stack>(); assert_eq!(stack.items.len(), 2); assert_eq!(stack.items[0].card, counterspell); assert_eq!(stack.items[1].card, lightning_bolt); // Resolve stack resolve_stack_completely(&mut app); // Verify both cards went to graveyard and Lightning Bolt didn't deal damage assert!(is_card_in_graveyard(&app, counterspell, player2)); assert!(is_card_in_graveyard(&app, lightning_bolt, player1)); let player2_resource = app.world.get::<Player>(player2).unwrap(); assert_eq!(player2_resource.life_total, 40); // Unchanged } }
Testing Best Practices
Arrange-Act-Assert Pattern
Follow the Arrange-Act-Assert pattern in test implementation:
#![allow(unused)] fn main() { #[test] fn test_some_functionality() { // ARRANGE: Set up the test environment let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(SystemUnderTest); let test_entity = setup_test_entity(&mut app); // ACT: Perform the action being tested perform_action(&mut app, test_entity); app.update(); // Run systems // ASSERT: Verify expected outcomes let result = get_result(&app, test_entity); assert_eq!(result, expected_value); } }
Use Test Fixtures
Create reusable test fixtures to simplify test implementation:
#![allow(unused)] fn main() { // Fixture for tests involving combat fn setup_combat_fixture(app: &mut App) -> CombatFixture { app.add_plugins(MinimalPlugins) .add_plugin(CombatPlugin); let player1 = app.world.spawn((Player { life_total: 40, ..Default::default() })).id(); let player2 = app.world.spawn((Player { life_total: 40, ..Default::default() })).id(); let attacker = app.world.spawn(( Creature { power: 3, toughness: 3, ..Default::default() }, Permanent { controller: player1, ..Default::default() }, )).id(); let blocker = app.world.spawn(( Creature { power: 2, toughness: 2, ..Default::default() }, Permanent { controller: player2, ..Default::default() }, )).id(); CombatFixture { player1, player2, attacker, blocker, } } }
Focused Test Cases
Keep test cases focused on a single behavior or requirement:
#![allow(unused)] fn main() { // GOOD: Focused test #[test] fn creatures_with_deathtouch_destroy_blockers() { // Test setup let creature_with_deathtouch = setup_deathtouch_creature(); let normal_creature = setup_normal_creature(); // Combat interaction simulate_combat(creature_with_deathtouch, normal_creature); // Verification assert!(normal_creature.is_destroyed()); } // BAD: Unfocused test #[test] fn test_deathtouch_and_trample_and_first_strike() { // Too many interactions being tested at once // Makes it hard to understand test failures } }
Property-Based Testing
Use property-based testing for rules that should hold across many inputs:
#![allow(unused)] fn main() { #[test] fn test_mana_payment_properties() { proptest!(|(cost: ManaCost, mana_pool: ManaPool)| { // Property: If payment succeeds, the mana pool should decrease by exactly the cost let initial_total = mana_pool.total_mana(); let result = pay_mana_cost(cost, &mut mana_pool.clone()); if result.is_success { let new_total = mana_pool.total_mana(); let used_mana = initial_total - new_total; // The mana used should equal the cost prop_assert_eq!(used_mana, cost.total_mana()); } }); } }
Test Debugging Tools
Logging in Tests
Use descriptive logging to help debug test failures:
#![allow(unused)] fn main() { #[test] fn test_with_detailed_logging() { // Set up the test info!("Setting up test with player 1 having 3 creatures and player 2 having 2 enchantments"); // Configure logging level for test let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(LogPlugin { level: Level::DEBUG, filter: "test=debug,game_engine=debug".to_string(), }); // Detailed action description debug!("Player 1 casting board wipe spell"); // Log outcome for debugging info!("Expected: All creatures destroyed, enchantments remain. Got: {} creatures, {} enchantments", remaining_creatures, remaining_enchantments); } }
State Snapshots
Create snapshots of game state for easier debugging:
#![allow(unused)] fn main() { #[test] fn test_complex_interaction() { let mut app = App::new(); // Setup test // Take snapshot before action let before_snapshot = take_game_state_snapshot(&app); save_snapshot("before_action.json", &before_snapshot); // Perform action perform_complex_action(&mut app); // Take snapshot after action let after_snapshot = take_game_state_snapshot(&app); save_snapshot("after_action.json", &after_snapshot); // Verify expected changes verify_state_changes(&before_snapshot, &after_snapshot); } }
Performance Testing
Include performance testing as part of your test suite:
#![allow(unused)] fn main() { #[test] fn benchmark_large_board_state() { let mut app = App::new(); app.add_plugins(GameEngineTestPlugins); // Create a large board state setup_large_board_state(&mut app, 100); // 100 permanents per player // Measure time for operations let start = std::time::Instant::now(); process_turn_cycle(&mut app); let duration = start.elapsed(); // Log performance results info!("Processing turn with large board took: {:?}", duration); // Assert performance requirements assert!(duration < std::time::Duration::from_millis(100), "Turn processing too slow: {:?}", duration); } }
Continuous Integration
Ensure your tests run in CI:
# .github/workflows/tests.yml
name: Game Engine Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
- name: Run unit tests
run: cargo test --lib
- name: Run integration tests
run: cargo test --test integration
- name: Run visual tests
run: cargo test --test visual
- name: Run performance tests
run: cargo test --test performance
Common Testing Patterns
Singleton Resource Validation
#![allow(unused)] fn main() { #[test] fn test_resource_update() { let mut app = App::new(); app.add_plugins(MinimalPlugins); // Add initial resource app.insert_resource(GameState { turn_count: 0, active_player: 0, }); // Add system that increments turn count app.add_systems(Update, increment_turn_system); // Run system app.update(); // Verify resource was updated let game_state = app.world.resource::<GameState>(); assert_eq!(game_state.turn_count, 1); } }
Event Testing
#![allow(unused)] fn main() { #[test] fn test_event_handling() { let mut app = App::new(); app.add_plugins(MinimalPlugins); // Add event and system app.add_event::<CardDrawEvent>(); app.add_systems(Update, handle_card_draw); // Send test event app.world.resource_mut::<Events<CardDrawEvent>>().send(CardDrawEvent { player: Entity::from_raw(1), count: 3, }); // Run system app.update(); // Verify event was handled (check side effects) // ... } }
Component Addition/Removal
#![allow(unused)] fn main() { #[test] fn test_component_addition() { let mut app = App::new(); app.add_plugins(MinimalPlugins); // System that adds components app.add_systems(Update, add_damage_component_system); // Create test entity let entity = app.world.spawn(Creature { power: 2, toughness: 2, }).id(); // Trigger damage app.world.resource_mut::<Events<DamageEvent>>().send(DamageEvent { target: entity, amount: 1, }); // Run system app.update(); // Verify component was added assert!(app.world.get::<Damaged>(entity).is_some()); assert_eq!(app.world.get::<Damaged>(entity).unwrap().amount, 1); } }
Conclusion
A comprehensive testing strategy is essential for the Rummage MTG Commander game engine. By combining unit tests, integration tests, end-to-end tests, and visual tests, we can ensure that the engine correctly implements the MTG rules and provides a consistent player experience.
Remember, an untested game engine is a source of bugs and inconsistencies. Invest time in creating a robust test suite, and it will pay dividends in reduced debugging time and improved game quality.
State-Based Actions
Overview
State-Based Actions (SBAs) are checks that the game automatically performs regularly, ensuring game rules are enforced without requiring player actions. These checks occur whenever a player would receive priority and before any player actually receives priority.
Core Functionality
In Commander, state-based actions handle crucial game state validations:
- Player loss conditions (life total at or below 0, drawing from an empty library, poison counters)
- Commander damage tracking (21+ damage from a single commander)
- Creature destruction (zero or negative toughness, lethal damage)
- Planeswalker loyalty management
- Aura and Equipment attachment rules
- Legendary permanents uniqueness rule
- Token existence rules
- Counters on permanents (especially +1/+1 and -1/-1 counter interaction)
Implementation
The state-based actions system is implemented as a collection of systems that run between steps and phases:
#![allow(unused)] fn main() { #[derive(Resource)] pub struct StateBasedActionSystem { // Configuration for SBA processing pub enabled: bool, pub last_check_time: Duration, pub check_frequency: Duration, // Tracking for specific SBAs pub pending_legendary_checks: Vec<Entity>, pub pending_destruction: Vec<Entity>, pub pending_life_checks: Vec<Entity>, } // Systems that implement specific checks pub fn check_player_loss_conditions(/* ... */); pub fn check_creature_destruction(/* ... */); pub fn check_attachment_validity(/* ... */); pub fn check_legendary_rule(/* ... */); pub fn check_token_existence(/* ... */); pub fn check_counter_interactions(/* ... */); }
Special Commander Rules
Commander format introduces specific state-based actions:
- Commander Damage: When a player has been dealt 21 or more combat damage by the same commander, they lose the game
- Commander Zone Transitions: Commanders can be moved to the command zone instead of other zones
- Color Identity: Cards that would be added to a library, hand, battlefield or graveyard are checked against the commander's color identity
Multiplayer Considerations
In multiplayer Commander:
- State-based actions are checked simultaneously for all players and permanents
- Player elimination is handled through SBAs, with appropriate triggers firing
- When a player leaves the game, all cards they own leave the game (with special rules for controlled permanents)
Optimization
The SBA system is optimized to:
- Only check relevant game objects (using spatial partitioning where appropriate)
- Use efficient data structures to track pending actions
- Batch similar checks where possible
- Cache results when appropriate
Commander-Specific Triggered Abilities
This document details the implementation of triggered abilities that are specific to or modified by the Commander format.
Overview
Triggered abilities in Commander generally follow the same rules as in standard Magic: The Gathering. However, there are several Commander-specific triggers and interactions that require special implementation:
- Triggers that reference the command zone
- Commander-specific card triggers
- Multiplayer-specific triggers
- Commander death and zone change triggers
Command Zone Triggers
Triggered abilities that interact with the command zone require special handling:
#![allow(unused)] fn main() { /// Component for abilities that trigger when a commander enters or leaves the command zone #[derive(Component)] pub struct CommandZoneTrigger { /// The event that triggers this ability pub trigger_event: CommandZoneEvent, /// The effect to apply pub effect: Box<dyn CommandZoneEffect>, } /// Events related to the command zone #[derive(Debug, Clone, PartialEq, Eq)] pub enum CommandZoneEvent { /// Triggers when a commander enters the command zone OnEnter, /// Triggers when a commander leaves the command zone OnLeave, /// Triggers when a commander is cast from the command zone OnCast, } /// System that handles command zone triggers pub fn handle_command_zone_triggers( mut commands: Commands, mut zone_transition_events: EventReader<ZoneTransitionEvent>, mut cast_events: EventReader<CastFromCommandZoneEvent>, command_zone_triggers: Query<(Entity, &CommandZoneTrigger, &Controller)>, commanders: Query<(Entity, &Commander, &Owner)>, ) { // Handle zone transitions for event in zone_transition_events.read() { // Check if this is a commander if let Ok((commander_entity, _, owner)) = commanders.get(event.entity) { // Handle entering command zone if event.to == Zone::Command { trigger_command_zone_abilities( CommandZoneEvent::OnEnter, commander_entity, owner.0, &command_zone_triggers, &mut commands, ); } // Handle leaving command zone if event.from == Zone::Command { trigger_command_zone_abilities( CommandZoneEvent::OnLeave, commander_entity, owner.0, &command_zone_triggers, &mut commands, ); } } } // Handle cast events for event in cast_events.read() { trigger_command_zone_abilities( CommandZoneEvent::OnCast, event.commander, event.controller, &command_zone_triggers, &mut commands, ); } } fn trigger_command_zone_abilities( event: CommandZoneEvent, commander: Entity, controller: Entity, triggers: &Query<(Entity, &CommandZoneTrigger, &Controller)>, commands: &mut Commands, ) { // Find all triggers that match this event for (trigger_entity, trigger, trigger_controller) in triggers.iter() { // Only trigger if the ability belongs to the controller of the commander if trigger_controller.0 == controller && trigger.trigger_event == event { // Create and resolve the triggered ability let ability_id = commands.spawn_empty().id(); // Set up ability context let mut ctx = AbilityContext { ability: ability_id, source: trigger_entity, controller, targets: vec![commander], additional_data: HashMap::new(), }; // Apply the effect trigger.effect.resolve(commands.world_mut(), &mut ctx); } } } }
Commander Death Triggers
The Commander format has specific rules about commanders changing zones and death triggers:
#![allow(unused)] fn main() { /// System that handles commander death triggers pub fn handle_commander_death_triggers( mut death_events: EventReader<DeathEvent>, mut commander_death_events: EventWriter<CommanderDeathEvent>, commanders: Query<&Commander>, ) { for event in death_events.read() { if commanders.contains(event.entity) { // Create a commander-specific death event commander_death_events.send(CommanderDeathEvent { commander: event.entity, controller: event.controller, cause: event.cause.clone(), }); } } } /// Event for when a commander dies #[derive(Event)] pub struct CommanderDeathEvent { /// The commander that died pub commander: Entity, /// The controller of the commander pub controller: Entity, /// What caused the death pub cause: DeathCause, } /// System that handles the option to put commander in command zone instead of graveyard pub fn handle_commander_zone_choice( mut commands: Commands, mut death_events: EventReader<DeathEvent>, mut zone_choice_events: EventWriter<CommanderZoneChoiceEvent>, commanders: Query<&Commander>, owners: Query<&Owner>, ) { for event in death_events.read() { if commanders.contains(event.entity) { if let Ok(owner) = owners.get(event.entity) { // Create a zone choice event for the commander's owner zone_choice_events.send(CommanderZoneChoiceEvent { commander: event.entity, owner: owner.0, source_zone: Zone::Battlefield, destination_options: vec![Zone::Graveyard, Zone::Command], }); } } } } }
Lieutenant Triggers
"Lieutenant" is a Commander-specific mechanic that gives bonuses when you control your commander:
#![allow(unused)] fn main() { /// Component for Lieutenant abilities #[derive(Component)] pub struct Lieutenant { /// The effect that happens when you control your commander pub effect: Box<dyn LieutenantEffect>, } /// System that checks for and applies Lieutenant effects pub fn check_lieutenant_condition( mut commands: Commands, lieutenant_cards: Query<(Entity, &Lieutenant, &Controller)>, battlefield: Query<(Entity, &Controller), With<OnBattlefield>>, commanders: Query<(Entity, &Commander)>, ) { // For each card with a Lieutenant ability for (lieutenant_entity, lieutenant, controller) in lieutenant_cards.iter() { // Check if controller controls their commander on the battlefield let controls_commander = battlefield .iter() .filter(|(entity, card_controller)| { // Must be controlled by same player as Lieutenant card card_controller.0 == controller.0 && // Must be a commander commanders.contains(*entity) }) .count() > 0; // Apply or remove the Lieutenant effect based on condition if controls_commander { // Apply the effect if not already applied if !commands.entity(lieutenant_entity).contains::<ActiveLieutenantEffect>() { commands.entity(lieutenant_entity).insert(ActiveLieutenantEffect); lieutenant.effect.apply(commands.world_mut(), lieutenant_entity); } } else { // Remove the effect if it was applied if commands.entity(lieutenant_entity).contains::<ActiveLieutenantEffect>() { commands.entity(lieutenant_entity).remove::<ActiveLieutenantEffect>(); lieutenant.effect.remove(commands.world_mut(), lieutenant_entity); } } } } /// Marker component for entities with active Lieutenant effects #[derive(Component)] pub struct ActiveLieutenantEffect; }
Partner Triggers
Partner commanders have unique triggered abilities:
#![allow(unused)] fn main() { /// Component for "Partner with" tutor ability #[derive(Component)] pub struct PartnerWithTutorAbility { /// The name of the partner card to search for pub partner_name: String, } /// System that handles the "Partner with" tutor trigger pub fn handle_partner_tutor_trigger( mut commands: Commands, mut entered_battlefield_events: EventReader<EnteredBattlefieldEvent>, partner_tutors: Query<(Entity, &PartnerWithTutorAbility, &Controller)>, mut tutor_events: EventWriter<TutorEvent>, ) { for event in entered_battlefield_events.read() { if let Ok((entity, tutor, controller)) = partner_tutors.get(event.entity) { // Create a tutor event to find the partner tutor_events.send(TutorEvent { player: controller.0, card_name: tutor.partner_name.clone(), destination_zone: Zone::Hand, source: entity, optional: true, }); } } } }
Commander Damage Triggers
Commander damage has a specific trigger at 21 damage:
#![allow(unused)] fn main() { /// System that checks for lethal commander damage pub fn check_commander_damage( mut commands: Commands, mut damage_events: EventReader<CommanderDamageEvent>, mut defeat_events: EventWriter<PlayerDefeatEvent>, commander_damage: Query<&CommanderDamageTracker>, ) { for event in damage_events.read() { // Get damage tracker for the damaged player if let Ok(damage_tracker) = commander_damage.get(event.damaged_player) { // Check if any commander has dealt 21+ damage for (commander, damage) in damage_tracker.damage.iter() { if *damage >= 21 { // Player is defeated by commander damage defeat_events.send(PlayerDefeatEvent { player: event.damaged_player, defeat_reason: DefeatReason::CommanderDamage { commander: *commander, }, }); break; } } } } } }
Multiplayer-Specific Triggers
Commander's multiplayer nature leads to unique triggered abilities:
#![allow(unused)] fn main() { /// Component for "attack trigger" abilities that scale with number of opponents attacked #[derive(Component)] pub struct AttackTriggeredAbility { /// The effect to apply pub effect: Box<dyn AttackEffect>, /// Multiplier for multi-opponent attacks pub scales_with_opponents: bool, } /// System that handles attack triggers in multiplayer pub fn handle_attack_triggers( mut commands: Commands, mut attack_events: EventReader<AttackEvent>, attack_abilities: Query<(Entity, &AttackTriggeredAbility, &Controller)>, ) { // Group attacks by controller to count unique opponents let mut controller_attacks: HashMap<Entity, HashSet<Entity>> = HashMap::new(); for event in attack_events.read() { if let Ok(attacker_controller) = controllers.get(event.attacker) { controller_attacks .entry(attacker_controller.0) .or_default() .insert(event.defender); } } // Trigger abilities based on attacks for (ability_entity, ability, controller) in attack_abilities.iter() { if let Some(attacked_opponents) = controller_attacks.get(&controller.0) { if !attacked_opponents.is_empty() { if ability.scales_with_opponents { // Apply effect with scaling factor let scale_factor = attacked_opponents.len() as i32; ability.effect.apply_scaled( commands.world_mut(), ability_entity, scale_factor ); } else { // Apply effect normally ability.effect.apply(commands.world_mut(), ability_entity); } } } } } }
Testing Commander Triggers
Testing Commander-specific triggers requires special test fixtures:
#![allow(unused)] fn main() { #[test] fn test_lieutenant_trigger() { let mut app = App::new(); setup_test_game(&mut app); // Create a player let player = app.world.spawn(Player).id(); // Create a commander in the command zone let commander = app.world.spawn(( CardName("Test Commander".to_string()), Commander, Controller(player), )).id(); // Create a card with Lieutenant ability let lieutenant = app.world.spawn(( CardName("Test Lieutenant".to_string()), OnBattlefield, Controller(player), Lieutenant { effect: Box::new(TestLieutenantEffect), }, )).id(); // Run the lieutenant check system app.update(); // Initially, commander not on battlefield, so no effect assert!(!app.world.entity(lieutenant).contains::<ActiveLieutenantEffect>()); // Move commander to battlefield app.world.entity_mut(commander).insert(OnBattlefield); // Run the lieutenant check system again app.update(); // Now the effect should be active assert!(app.world.entity(lieutenant).contains::<ActiveLieutenantEffect>()); // Remove commander from battlefield app.world.entity_mut(commander).remove::<OnBattlefield>(); // Run system again app.update(); // Effect should be removed assert!(!app.world.entity(lieutenant).contains::<ActiveLieutenantEffect>()); } }
Integration with Core Triggered Abilities
Commander-specific triggered abilities integrate with the core triggered ability system:
#![allow(unused)] fn main() { pub fn register_commander_triggered_abilities(app: &mut App) { app .add_event::<CommanderDeathEvent>() .add_event::<CommanderZoneChoiceEvent>() .add_systems(Update, ( handle_command_zone_triggers, handle_commander_death_triggers, handle_commander_zone_choice, check_lieutenant_condition, handle_partner_tutor_trigger, check_commander_damage, handle_attack_triggers, ).after(CoreTriggerSystems)); } }
Related Resources
- Commander Death Triggers
- Partner Commanders
- Commander Damage
- Command Zone
- Core Triggered Abilities Documentation
Random Elements in Commander
This document outlines how random elements in Magic: The Gathering are implemented in the Commander format within Rummage.
Types of Random Elements
Magic: The Gathering contains several types of random elements that require implementation in a digital engine:
Element | Description | Examples |
---|---|---|
Coin Flips | Binary random outcome | Krark's Thumb, Mana Crypt |
Die Rolls | Random number generation | Delina, Wild Mage; Chaos effects |
Random Card Selection | Selecting cards at random | Goblin Lore, Chaos Warp |
Random Target Selection | Choosing targets randomly | Possibility Storm, Knowledge Pool |
Random Card Generation | Creating cards not in the original deck | Booster Tutor, Garth One-Eye |
Implementation Approach
In Rummage, we implement random elements using a deterministic RNG system that:
- Maintains synchronization across networked games
- Provides verifiable randomness that can be audited
- Seeds random generators consistently across client instances
- Allows for replaying game states with identical outcomes
Code Example: Deterministic RNG
#![allow(unused)] fn main() { use bevy::prelude::*; use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha8Rng; #[derive(Resource)] pub struct GameRng(ChaCha8Rng); impl GameRng { pub fn new_seeded(seed: u64) -> Self { Self(ChaCha8Rng::seed_from_u64(seed)) } pub fn flip_coin(&mut self) -> bool { self.0.gen_bool(0.5) } pub fn roll_die(&mut self, sides: u32) -> u32 { self.0.gen_range(1..=sides) } pub fn select_random_index(&mut self, max: usize) -> usize { if max == 0 { return 0; } self.0.gen_range(0..max) } } // System that handles coin flips fn handle_coin_flip( mut commands: Commands, mut rng: ResMut<GameRng>, mut coin_flip_events: EventReader<CoinFlipEvent>, ) { for event in coin_flip_events.read() { let result = rng.flip_coin(); commands.spawn(CoinFlipResult { source: event.source, result, affected_entities: event.affected_entities.clone(), }); } } }
Synchronization with Multiplayer
In multiplayer Commander games, random outcomes must be identical for all players. To achieve this:
- The host acts as the source of truth for RNG seed
- RNG state is included in the synchronized game state
- Random events are processed deterministically
- Network desync detection checks RNG state
Testing Random Elements
Random elements require special testing approaches:
- Seeded Tests: Using known seeds to produce predictable outcomes
- Distribution Tests: Verifying statistical properties over many trials
- Edge Case Tests: Testing boundary conditions and extreme outcomes
- Determinism Tests: Ensuring identical outcomes given the same seed
Example Test
#![allow(unused)] fn main() { #[test] fn test_coin_flip_determinism() { // Create two RNGs with the same seed let mut rng1 = GameRng::new_seeded(12345); let mut rng2 = GameRng::new_seeded(12345); // They should produce identical sequences for _ in 0..100 { assert_eq!(rng1.flip_coin(), rng2.flip_coin()); } } }
Commander-Specific Concerns
In Commander, random elements interact with multiplayer politics in unique ways:
- Perception of fairness in random outcomes
- Impact of random effects on multiple players simultaneously
- Using random outcomes as leverage in political negotiations
- Varying player attitudes toward randomness and luck
Our implementation supports these dynamics by:
- Providing clear visualization of random processes
- Implementing rules for modifying random outcomes (like Krark's Thumb)
- Supporting multiplayer-specific random effects
- Allowing customization of random elements in house rules
Summary
Random elements in Commander are implemented using deterministic, network-synchronized systems that maintain game integrity while supporting the unique social and political dynamics of the format.
Game State Management
Overview
The Game State Management module is responsible for tracking and maintaining the complete state of a Commander game. It serves as the central source of truth for all game data and coordinates the interactions between other modules, ensuring rules compliance according to the Magic: The Gathering Comprehensive Rules section 903.
Core Components
GameState Resource
#![allow(unused)] fn main() { #[derive(Resource)] pub struct CommanderGameState { // Game metadata pub game_id: uuid::Uuid, pub turn_number: u32, pub start_time: std::time::Instant, // Active player and turn state pub active_player_index: usize, pub player_order: Vec<Entity>, pub current_phase: Phase, pub priority_holder: Entity, // Game parameters pub starting_life: u32, // 40 for Commander (rule 903.7) pub max_players: u8, // Up to 13 supported pub commander_damage_threshold: u32, // 21 (rule 903.10a) // Game state flags pub is_game_over: bool, pub winner: Option<Entity>, // Special Commander format states pub commander_cast_count: HashMap<Entity, u32>, // For commander tax pub eliminated_players: Vec<Entity>, } }
State Tracking Components
#![allow(unused)] fn main() { #[derive(Component)] pub struct CommanderGameParticipant { pub player_index: usize, pub is_active: bool, } #[derive(Component)] pub struct TurnOrderPosition { pub position: usize, pub is_skipping_turn: bool, } #[derive(Component, Default)] pub struct GameStateFlags { pub has_played_land: bool, pub commanders_in_play: HashSet<Entity>, pub has_attacked_this_turn: bool, } }
Key Systems
Game State Initialization
#![allow(unused)] fn main() { pub fn initialize_commander_game( mut commands: Commands, mut game_state: ResMut<CommanderGameState>, players: Query<Entity, With<Player>>, ) { // Set up initial game state game_state.game_id = uuid::Uuid::new_v4(); game_state.turn_number = 1; game_state.start_time = std::time::Instant::now(); // Set up player order (randomized) let mut player_entities = players.iter().collect::<Vec<_>>(); player_entities.shuffle(&mut rand::thread_rng()); game_state.player_order = player_entities; // Set initial active player game_state.active_player_index = 0; game_state.priority_holder = player_entities[0]; // Set starting phase game_state.current_phase = Phase::Beginning(BeginningPhaseStep::Untap); // Initialize Commander-specific parameters game_state.starting_life = 40; // Commander rule 903.7 game_state.commander_damage_threshold = 21; // Commander rule 903.10a // Tag entities with player index for (idx, entity) in player_entities.iter().enumerate() { commands.entity(*entity).insert(CommanderGameParticipant { player_index: idx, is_active: idx == 0, }); commands.entity(*entity).insert(TurnOrderPosition { position: idx, is_skipping_turn: false, }); commands.entity(*entity).insert(GameStateFlags::default()); } } }
Game State Update System
#![allow(unused)] fn main() { pub fn update_game_state( mut game_state: ResMut<CommanderGameState>, mut players: Query<(Entity, &mut CommanderGameParticipant, &PlayerState)>, time: Res<Time>, ) { // Check for eliminated players for (entity, participant, state) in players.iter() { if state.life_total <= 0 && !game_state.eliminated_players.contains(&entity) { game_state.eliminated_players.push(entity); } } // Check for game over condition let remaining_players = players .iter() .filter(|(entity, _, _)| !game_state.eliminated_players.contains(&entity)) .count(); if remaining_players <= 1 { game_state.is_game_over = true; if remaining_players == 1 { let winner = players .iter() .find(|(entity, _, _)| !game_state.eliminated_players.contains(&entity)) .map(|(entity, _, _)| entity); game_state.winner = winner; } } } }
State Validation
The game state management module includes validation functions to ensure game state consistency:
#![allow(unused)] fn main() { pub fn validate_game_state(game_state: &CommanderGameState) -> Result<(), String> { // Validate player count if game_state.player_order.is_empty() { return Err("Game must have at least one player".to_string()); } if game_state.player_order.len() > game_state.max_players as usize { return Err(format!("Game cannot have more than {} players", game_state.max_players)); } // Validate active player if game_state.active_player_index >= game_state.player_order.len() { return Err("Active player index out of bounds".to_string()); } // More validations... Ok(()) } }
Integration Points
The game state module integrates with other systems through:
- Resource access - Other systems can read the game state
- Events - Game state changes trigger events for other systems
- Commands - Game state can be updated through commands
This module forms the foundation of the Commander game engine, providing the central state management needed to coordinate all other game systems.
Random Mechanics Test Cases
Overview
This document outlines test cases for Magic: The Gathering cards and mechanics involving randomness, including coin flips, dice rolls, and random selections. When implementing these mechanics, it's crucial to ensure deterministic testing while maintaining fair randomness in actual gameplay.
Randomness in Commander Format
The Commander format includes many cards with random elements that require special handling:
-
Commander-Specific Random Cards: Many popular Commander cards use coin flips, dice rolls, or random selection as a core mechanic (e.g., Okaun, Eye of Chaos, Zndrsplt, Eye of Wisdom, Yusri, Fortune's Flame)
-
Multiplayer Considerations: Random effects in multiplayer games need to ensure all players can verify the randomness
-
Deterministic Testing: While gameplay should be truly random, our testing requires deterministic outcomes
-
Network Implementation: In networked games, random results must be synchronized across all clients
This document provides test cases to ensure these mechanics work properly within the Commander format implementation.
Test Principles
- Deterministic Testing: All random operations must be mockable for testing
- Seed Control: Tests should use fixed seeds for reproducibility
- Distribution Validation: Test that random operations have the expected distribution over many trials
- User Interface: Test that random events are properly communicated to players
Coin Flip Test Cases
Basic Coin Flip Mechanics
#![allow(unused)] fn main() { #[test] fn test_basic_coin_flip() { // Create a test game with mocked RNG using a predetermined seed let mut game = TestGame::new_with_seed(12345); // Add Krark's Thumb to player 1's battlefield // "If you would flip a coin, instead flip two coins and ignore one of them." game.add_card_to_battlefield(1, "Krark's Thumb"); // Add Chance Encounter to player 1's battlefield // "Whenever you win a coin flip, put a luck counter on Chance Encounter. // At the beginning of your upkeep, if Chance Encounter has ten or more luck counters on it, you win the game." game.add_card_to_battlefield(1, "Chance Encounter"); // Cast Stitch in Time // "Flip a coin. If you win the flip, take an extra turn after this one." game.cast_spell(1, "Stitch in Time"); // Verify the coin flip sequence was triggered assert!(game.event_occurred(GameEvent::CoinFlipStarted)); // With our test seed, we know the first flip should be a win // and the second flip would be a loss, but Krark's Thumb lets us ignore one assert_eq!(game.get_extra_turns_count(1), 1); // A luck counter should be added to Chance Encounter assert_eq!(game.get_counters(Card::by_name("Chance Encounter"), CounterType::Luck), 1); } }
Commander with Coin Flip Abilities
#![allow(unused)] fn main() { #[test] fn test_okaun_zndrsplt_interaction() { // Create a test game with mocked RNG let mut game = TestGame::new_with_seed(42); // Add Okaun, Eye of Chaos and Zndrsplt, Eye of Wisdom as commanders for player 1 // Okaun: "Whenever you win a coin flip, double Okaun's power and toughness until end of turn." // Zndrsplt: "Whenever you win a coin flip, draw a card." game.add_commander(1, "Okaun, Eye of Chaos"); game.add_commander(1, "Zndrsplt, Eye of Wisdom"); // Cast Frenetic Efreet // "{0}: Flip a coin. If you win the flip, Frenetic Efreet phases out. // If you lose the flip, sacrifice Frenetic Efreet." game.cast_spell(1, "Frenetic Efreet"); // Activate ability three times, with predetermined results: win, lose, win game.set_next_coin_flips([true, false, true]); for _ in 0..3 { game.activate_ability(Card::by_name("Frenetic Efreet"), 0); game.resolve_stack(); } // Verify Okaun's power/toughness was doubled twice (for two wins) // Base P/T is 3/3, doubled twice = 12/12 let okaun = game.get_battlefield_card(1, "Okaun, Eye of Chaos"); assert_eq!(game.get_power(okaun), 12); assert_eq!(game.get_toughness(okaun), 12); // Verify player drew 2 cards from Zndrsplt's ability assert_eq!(game.get_hand_size(1), 2); // Verify Frenetic Efreet was sacrificed on the loss assert!(game.in_graveyard(Card::by_name("Frenetic Efreet"))); } }
Edge Cases
#![allow(unused)] fn main() { #[test] fn test_coin_flip_replacement_effects() { let mut game = TestGame::new_with_seed(7777); // Add Krark's Thumb to player 1's battlefield game.add_card_to_battlefield(1, "Krark's Thumb"); // Add Goblin Archaeologist to battlefield // "{T}: Flip a coin. If you win the flip, destroy target artifact. // If you lose the flip, sacrifice Goblin Archaeologist." game.add_card_to_battlefield(1, "Goblin Archaeologist"); // Add a target artifact game.add_card_to_battlefield(2, "Sol Ring"); // Add Chance Encounter game.add_card_to_battlefield(1, "Chance Encounter"); // Add Tavern Scoundrel // "Whenever you win one or more coin flips, create a Treasure token." game.add_card_to_battlefield(1, "Tavern Scoundrel"); // Setup test for replacement effect - ensure both flips are a "win" game.set_next_coin_flips([true, true]); // Activate Goblin Archaeologist targeting Sol Ring game.activate_ability_with_target( Card::by_name("Goblin Archaeologist"), 0, Card::by_name("Sol Ring") ); game.resolve_stack(); // Verify Sol Ring was destroyed assert!(game.in_graveyard(Card::by_name("Sol Ring"))); // Verify Goblin Archaeologist is still on the battlefield assert!(game.on_battlefield(Card::by_name("Goblin Archaeologist"))); // Verify we got only ONE Treasure token despite flipping two coins // (Tavern Scoundrel triggers on "one or more" wins, not per win) assert_eq!(game.count_permanents_by_type(1, "Treasure"), 1); // Verify Chance Encounter got a luck counter assert_eq!(game.get_counters(Card::by_name("Chance Encounter"), CounterType::Luck), 1); } }
Dice Rolling Test Cases
Basic Dice Rolling
#![allow(unused)] fn main() { #[test] fn test_basic_dice_rolling() { let mut game = TestGame::new_with_seed(42); // Add Delina, Wild Mage to battlefield // "Whenever Delina, Wild Mage attacks, roll a d20. If you roll a 15 or higher, // create a token that's a copy of target creature you control..." game.add_card_to_battlefield(1, "Delina, Wild Mage"); // Add a target creature game.add_card_to_battlefield(1, "Llanowar Elves"); // Set up attack game.begin_combat(); game.declare_attacker(Card::by_name("Delina, Wild Mage"), 2); // Mock the d20 roll to be 17 game.set_next_dice_roll(20, 17); // Resolve Delina's triggered ability, targeting Llanowar Elves game.choose_target_for_ability( Card::by_name("Delina, Wild Mage"), Card::by_name("Llanowar Elves") ); game.resolve_stack(); // Verify a token copy was created assert_eq!(game.count_permanents_by_name(1, "Llanowar Elves"), 2); // Verify the token has haste (part of Delina's ability) let token = game.get_tokens_by_name(1, "Llanowar Elves")[0]; assert!(game.has_ability(token, Ability::Haste)); } }
Commander With Dice Rolling Abilities
#![allow(unused)] fn main() { #[test] fn test_farideh_commander() { let mut game = TestGame::new_with_seed(42); // Add Farideh, Devil's Chosen as commander for player 1 // "Whenever you roll one or more dice, Farideh, Devil's Chosen gains flying and menace until end of turn. // Whenever you roll a natural 20 on a d20, put a +1/+1 counter on Farideh." game.add_commander(1, "Farideh, Devil's Chosen"); // Cast Pixie Guide // "If you would roll a d20, instead roll two d20s and use the higher roll." game.cast_spell(1, "Pixie Guide"); // Cast Light of Hope // "Roll a d20. If you roll a 1-9, create a 3/3 Angel creature token with flying. // If you roll a 10-19, you gain 3 life for each creature you control. // If you roll a 20, until end of turn, creatures you control gain +2/+2 and acquire flying." game.cast_spell(1, "Light of Hope"); // Set dice rolls to 8 and 20 (Pixie Guide will use the 20) game.set_next_dice_rolls(20, [8, 20]); game.resolve_stack(); // Verify the abilities triggered by the natural 20 // 1. Creatures get +2/+2 and flying from Light of Hope // 2. Farideh gets a +1/+1 counter from her ability // 3. Farideh gets flying and menace from her first ability // Check Farideh's power/toughness (base 3/3 + 1 counter + 2 from Light of Hope = 6/6) let farideh = game.get_battlefield_card(1, "Farideh, Devil's Chosen"); assert_eq!(game.get_power(farideh), 6); assert_eq!(game.get_toughness(farideh), 6); assert_eq!(game.get_counters(farideh, CounterType::PlusOnePlusOne), 1); // Check abilities assert!(game.has_ability(farideh, Ability::Flying)); assert!(game.has_ability(farideh, Ability::Menace)); // Pixie Guide should also have +2/+2 and flying from Light of Hope let pixie = game.get_battlefield_card(1, "Pixie Guide"); assert_eq!(game.get_power(pixie), game.get_base_power(pixie) + 2); assert!(game.has_ability(pixie, Ability::Flying)); } }
Random Selection Test Cases
Random Discard
#![allow(unused)] fn main() { #[test] fn test_random_discard() { let mut game = TestGame::new_with_seed(42); // Set up player 2's hand with known cards let cards = ["Island", "Lightning Bolt", "Dark Ritual", "Divination", "Doom Blade"]; for card in cards { game.add_card_to_hand(2, card); } // Cast Hypnotic Specter with player 1 game.add_card_to_battlefield(1, "Hypnotic Specter"); // Deal damage to trigger discard game.deal_damage(Card::by_name("Hypnotic Specter"), Player(2), 1); // Set up the random discard to choose Lightning Bolt game.set_next_random_card_index(1); // 0-indexed, so this is the second card game.resolve_stack(); // Verify Lightning Bolt was discarded assert!(game.in_graveyard(Card::by_name("Lightning Bolt"))); assert_eq!(game.get_hand_size(2), 4); } }
Random Target Selection
#![allow(unused)] fn main() { #[test] fn test_random_target_selection() { let mut game = TestGame::new_with_seed(42); // Setup battlefield with multiple creatures game.add_card_to_battlefield(2, "Grizzly Bears"); game.add_card_to_battlefield(2, "Hill Giant"); game.add_card_to_battlefield(2, "Shivan Dragon"); // Cast Confusion in the Ranks with player 1 // "Whenever a permanent enters the battlefield, its controller chooses target permanent // another player controls that shares a type with it. Exchange control of those permanents." game.add_card_to_battlefield(1, "Confusion in the Ranks"); // Cast Mogg Fanatic (creature) game.cast_spell(1, "Mogg Fanatic"); // Mogg Fanatic enters, triggering Confusion in the Ranks // Set the random target to be Hill Giant (index 1 of the 3 valid targets) game.set_next_random_target_index(1); game.resolve_stack(); // Verify Hill Giant is now controlled by player 1 assert_eq!(game.get_controller(Card::by_name("Hill Giant")), Player(1)); // Verify Mogg Fanatic is now controlled by player 2 assert_eq!(game.get_controller(Card::by_name("Mogg Fanatic")), Player(2)); } }
Integration with Commander Rules
Commander Damage with Chaotic Strike
#![allow(unused)] fn main() { #[test] fn test_commander_damage_with_random_double_strike() { let mut game = TestGame::new_with_seed(42); // Setup Player 1's commander game.add_commander(1, "Gisela, Blade of Goldnight"); // Cast Chaos Warp targeting an unimportant permanent // "The owner of target permanent shuffles it into their library, // then reveals the top card of their library. If it's a permanent card, // they put it onto the battlefield." game.add_card_to_hand(1, "Chaos Warp"); game.cast_spell_with_target(1, "Chaos Warp", Card::by_name("Forest")); // Set up the random reveal to be Berserker's Onslaught // "Attacking creatures you control have double strike." game.set_next_reveal_card("Berserker's Onslaught"); game.resolve_stack(); // Move to combat game.begin_combat(); game.declare_attacker(Card::by_name("Gisela, Blade of Goldnight"), 2); // Resolve combat damage game.resolve_combat_damage(); // Verify commander damage - should be doubled from Berserker's Onslaught // Gisela is a 5/5 flying first strike, so should deal 10 commander damage with double strike assert_eq!(game.get_commander_damage(2, Card::by_name("Gisela, Blade of Goldnight")), 10); } }
Mock Framework for Testing Random Events
The test cases above reference various functions for mocking random events. Here's an example implementation for the test framework:
#![allow(unused)] fn main() { impl TestGame { /// Create a new test game with a fixed seed for reproducible randomness pub fn new_with_seed(seed: u64) -> Self { let mut game = Self::new(); game.set_rng_seed(seed); game } /// Set the results of the next coin flips pub fn set_next_coin_flips(&mut self, results: impl Into<Vec<bool>>) { self.coin_flip_results = results.into(); } /// Set the result of a single upcoming coin flip pub fn set_next_coin_flip(&mut self, result: bool) { self.coin_flip_results = vec![result]; } /// Set the results of the next dice rolls of a specific size (e.g., d20) pub fn set_next_dice_rolls(&mut self, sides: u32, results: impl Into<Vec<u32>>) { self.dice_roll_results.insert(sides, results.into()); } /// Set the result of a single upcoming dice roll pub fn set_next_dice_roll(&mut self, sides: u32, result: u32) { self.dice_roll_results.insert(sides, vec![result]); } /// Set which card will be chosen in a random selection (by index) pub fn set_next_random_card_index(&mut self, index: usize) { self.random_card_indices = vec![index]; } /// Set which target will be chosen in a random selection (by index) pub fn set_next_random_target_index(&mut self, index: usize) { self.random_target_indices = vec![index]; } /// Set the next card to be revealed from a library pub fn set_next_reveal_card(&mut self, card_name: &str) { self.next_reveal_cards.push(card_name.to_string()); } } }
Implementation Notes
- All random operations should be abstracted through an
RngService
to allow for deterministic testing. - The
RngService
should be injectable and replaceable with a mock version for tests. - Test cases should validate both mechanical correctness and expected probabilities.
- The UI should clearly communicate random events to players, with appropriate animations.
Future Considerations
- Testing network lag effects on synchronization of random events in multiplayer games
- Validating fairness of random number generation over large sample sizes
- Implementing proper security measures to prevent cheating in networked games
Player Mechanics
This section covers player-specific mechanics in the Commander format implementation.
Contents
- Player Management - Player data structures and basic player operations
- Commander Damage - Tracking and implementing the 21-damage rule
- Multiplayer Politics - Voting, deals, and other social mechanics
The player mechanics section defines how players interact within the Commander format, including:
- Starting with 40 life (compared to 20 in standard Magic)
- Commander damage tracking (21+ combat damage from a single commander causes a loss)
- Turn order and priority management in multiplayer
- Special multiplayer interactions like voting, monarch status, and political deals
- Player elimination and handling of player-owned objects after elimination
These mechanics are essential for correctly implementing the multiplayer aspects of Commander, especially with games supporting between 2 and 13 players.
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
Commander Tax
This document explains the implementation of the Commander Tax mechanic in the Rummage game engine.
Overview
Commander Tax is a core rule of the Commander format that increases the cost to cast your commander from the command zone by {2} for each previous time you've cast it from the command zone during the game.
The rule ensures that:
- Players cannot repeatedly abuse their commander's abilities by letting it die and recasting it
- The game becomes progressively more challenging as players need to invest more mana into recasting their commander
- Decks need to consider their commander's mana value when building their strategy
Formula
The cost to cast a commander is calculated as:
Total Cost = Base Cost + (2 × Number of Previous Casts)
For example:
- First cast: Base cost
- Second cast: Base cost + {2}
- Third cast: Base cost + {4}
- Fourth cast: Base cost + {6}
Implementation
Commander Component
#![allow(unused)] fn main() { /// Component for commanders #[derive(Component, Debug, Clone, Reflect)] pub struct Commander { /// Entity ID of the player who owns this commander pub owner: Entity, /// Number of times this commander has been cast from the command zone pub cast_count: u32, /// Whether this commander is in the command zone pub in_command_zone: bool, } impl Default for Commander { fn default() -> Self { Self { owner: Entity::PLACEHOLDER, cast_count: 0, in_command_zone: true, } } } }
Commander Tax Calculation
#![allow(unused)] fn main() { /// Calculates the total cost to cast a commander pub fn calculate_commander_cost( base_cost: Mana, commander: &Commander, ) -> Mana { // Apply commander tax: 2 generic mana for each previous cast let tax_amount = commander.cast_count * 2; // Create a new mana cost with additional generic mana let mut total_cost = base_cost.clone(); total_cost.colorless += tax_amount; total_cost } }
Casting System
#![allow(unused)] fn main() { /// System to handle commander casting pub fn handle_commander_casting( mut commands: Commands, mut commanders: Query<(Entity, &mut Commander, &CardCost)>, mut cast_events: EventReader<CastCommanderEvent>, mut mana_events: EventWriter<ManaCostEvent>, ) { for event in cast_events.read() { if let Ok((entity, mut commander, cost)) = commanders.get_mut(event.commander) { // Calculate total cost with commander tax let total_cost = calculate_commander_cost(cost.mana.clone(), &commander); // Send event to check if player can pay the cost mana_events.send(ManaCostEvent { player: commander.owner, source: entity, cost: total_cost, on_paid: CastEffect::Commander { entity }, }); // Increment cast count for next time if event.successful { commander.cast_count += 1; commander.in_command_zone = false; } } } } }
Commander Tax Tracking
The UI displays the current commander tax for each player's commander:
- Current cast count is shown next to the commander in the command zone
- The additional cost is displayed when hovering over the commander in the command zone
- The total cost (base + tax) is shown when attempting to cast the commander
Special Interactions
Tax Reduction Effects
Some cards can reduce the commander tax:
#![allow(unused)] fn main() { /// Component for effects that reduce commander tax #[derive(Component, Debug, Clone, Reflect)] pub struct CommanderTaxReduction { /// Amount to reduce the tax by pub amount: u32, /// Whether this applies to all commanders or specific ones pub targets: CommanderTaxReductionTarget, } /// Different types of tax reduction targets #[derive(Debug, Clone, Reflect)] pub enum CommanderTaxReductionTarget { /// Applies to all of your commanders AllOwn, /// Applies to all commanders (including opponents') All, /// Applies to specific commanders Specific(Vec<Entity>), } }
Partner Commanders
Partner commanders track their tax separately:
#![allow(unused)] fn main() { /// When checking if a player has a partner commander pub fn handle_partner_commander_tax( partners: Query<(Entity, &Commander, &Partner)>, // ... other parameters ) { // Each partner tracks its own commander tax separately // ... } }
Testing
Test Cases
We test the commander tax mechanics with:
- Basic Tax Progression: Verify tax increases correctly with each cast
- Tax After Zone Changes: Ensure tax only increases when cast from command zone
- Tax Reduction Effects: Test cards that reduce commander tax
- Partner Commander Interaction: Verify partners track tax separately
Example Test
#![allow(unused)] fn main() { #[test] fn test_commander_tax_progression() { // Set up test environment let mut app = App::new(); app.add_systems(Update, handle_commander_casting) .add_event::<CastCommanderEvent>() .add_event::<ManaCostEvent>(); // Create a player and commander let player = app.world.spawn_empty().id(); let base_cost = Mana::new(2, 1, 0, 0, 0, 0); // 2G let commander = app.world.spawn(( Commander { owner: player, cast_count: 0, in_command_zone: true, }, CardCost { mana: base_cost.clone() }, )).id(); // First cast (should be base cost) app.world.send_event(CastCommanderEvent { commander, successful: true, }); app.update(); // Check commander was updated let cmdr = app.world.get::<Commander>(commander).unwrap(); assert_eq!(cmdr.cast_count, 1); // Second cast (should be base cost + {2}) app.world.send_event(CastCommanderEvent { commander, successful: true, }); app.update(); // Verify tax increased let cmdr = app.world.get::<Commander>(commander).unwrap(); assert_eq!(cmdr.cast_count, 2); // Verify correct cost calculation let cmdr = app.world.get::<Commander>(commander).unwrap(); let total_cost = calculate_commander_cost(base_cost, cmdr); assert_eq!(total_cost.colorless, 6); // Base 2 + 4 tax assert_eq!(total_cost.green, 1); // Colored cost unchanged } }
Summary
The Commander Tax mechanic in Rummage is implemented as a dynamic cost increase system that:
- Tracks cast count per commander
- Applies the correct tax formula
- Supports complex interactions like cost reduction effects
- Handles partner commanders appropriately
- Provides clear UI feedback on current tax amounts
Color Identity in Commander
This document explains how color identity is implemented in the Rummage game engine for the Commander format.
Color Identity Rules
In Commander, a card's color identity determines which decks it can be included in. The color identity rules are:
-
A card's color identity includes all colored mana symbols that appear:
- In its mana cost
- In its rules text
- On either face of a double-faced card
- On all parts of a split or adventure card
-
Color identity is represented by the colors: White, Blue, Black, Red, and Green
-
A card's color identity can include colors even if the card itself is not those colors
-
Cards in a commander deck must only use colors within the color identity of the deck's commander
-
Basic lands with intrinsic mana abilities are only legal in decks where all their produced colors are in the commander's color identity
Component Implementation
#![allow(unused)] fn main() { /// Component representing a card's color identity #[derive(Component, Debug, Clone, Reflect)] pub struct ColorIdentity { /// White in color identity pub white: bool, /// Blue in color identity pub blue: bool, /// Black in color identity pub black: bool, /// Red in color identity pub red: bool, /// Green in color identity pub green: bool, } impl ColorIdentity { /// Creates a new color identity from a set of colors pub fn new(white: bool, blue: bool, black: bool, red: bool, green: bool) -> Self { Self { white, blue, black, red, green, } } /// Creates a colorless identity pub fn colorless() -> Self { Self::new(false, false, false, false, false) } /// Checks if this color identity contains another pub fn contains(&self, other: &ColorIdentity) -> bool { (!other.white || self.white) && (!other.blue || self.blue) && (!other.black || self.black) && (!other.red || self.red) && (!other.green || self.green) } /// Gets the colors as an array of booleans [W, U, B, R, G] pub fn as_array(&self) -> [bool; 5] { [self.white, self.blue, self.black, self.red, self.green] } /// Gets the number of colors in this identity pub fn color_count(&self) -> usize { self.as_array().iter().filter(|&&color| color).count() } /// Checks if this is a colorless identity pub fn is_colorless(&self) -> bool { self.color_count() == 0 } } }
Calculating Color Identity
The system for calculating a card's color identity needs to analyze all text and symbols on the card:
#![allow(unused)] fn main() { /// Calculates a card's color identity from its various components pub fn calculate_color_identity( mana_cost: &Mana, rules_text: &str, card_type: &CardTypes, ) -> ColorIdentity { let mut identity = ColorIdentity::colorless(); // Check mana cost identity.white = identity.white || mana_cost.white > 0; identity.blue = identity.blue || mana_cost.blue > 0; identity.black = identity.black || mana_cost.black > 0; identity.red = identity.red || mana_cost.red > 0; identity.green = identity.green || mana_cost.green > 0; // Check rules text for mana symbols using regex let re = Regex::new(r"\{([WUBRG])/([WUBRG])\}|\{([WUBRG])\}").unwrap(); for cap in re.captures_iter(rules_text) { if let Some(hybrid_1) = cap.get(1) { match hybrid_1.as_str() { "W" => identity.white = true, "U" => identity.blue = true, "B" => identity.black = true, "R" => identity.red = true, "G" => identity.green = true, _ => {} } } if let Some(hybrid_2) = cap.get(2) { match hybrid_2.as_str() { "W" => identity.white = true, "U" => identity.blue = true, "B" => identity.black = true, "R" => identity.red = true, "G" => identity.green = true, _ => {} } } if let Some(single) = cap.get(3) { match single.as_str() { "W" => identity.white = true, "U" => identity.blue = true, "B" => identity.black = true, "R" => identity.red = true, "G" => identity.green = true, _ => {} } } } // Check for color indicators if let Some(colors) = card_type.get_color_indicator() { for color in colors { match color { Color::White => identity.white = true, Color::Blue => identity.blue = true, Color::Black => identity.black = true, Color::Red => identity.red = true, Color::Green => identity.green = true, _ => {} } } } identity } }
Deck Validation
Deck validation ensures that all cards in a deck match the commander's color identity:
#![allow(unused)] fn main() { /// System to validate deck color identity during deck construction pub fn validate_deck_color_identity( commanders: Query<(&Commander, &ColorIdentity)>, cards: Query<(Entity, &InDeck, &ColorIdentity)>, mut validation_events: EventWriter<DeckValidationEvent>, ) { // For each deck let decks = cards.iter() .map(|(_, in_deck, _)| in_deck.deck) .collect::<HashSet<_>>(); for deck in decks { // Find the commander(s) for this deck let deck_commanders = commanders.iter() .filter(|(cmdr, _)| cmdr.deck == deck) .collect::<Vec<_>>(); if deck_commanders.is_empty() { validation_events.send(DeckValidationEvent::Invalid { deck, reason: "No commander found for deck".to_string(), }); continue; } // Calculate the combined color identity for all commanders (for partner support) let mut combined_identity = ColorIdentity::colorless(); for (_, identity) in deck_commanders.iter() { combined_identity.white |= identity.white; combined_identity.blue |= identity.blue; combined_identity.black |= identity.black; combined_identity.red |= identity.red; combined_identity.green |= identity.green; } // Check that all cards match the commander's color identity for (entity, in_deck, card_identity) in cards.iter() { if in_deck.deck != deck { continue; } if !combined_identity.contains(card_identity) { validation_events.send(DeckValidationEvent::InvalidCard { deck, card: entity, reason: format!("Card color identity outside of commander's color identity"), }); } } } } }
Special Cases
Partner Commanders
When using partner commanders, the deck's color identity is the union of both commanders' color identities:
#![allow(unused)] fn main() { /// System to handle partner commanders' combined color identity pub fn handle_partner_color_identity( partners: Query<(Entity, &Partner, &ColorIdentity)>, decks: Query<&Deck>, ) { // Group partners by deck let mut deck_partners = HashMap::new(); for (entity, partner, identity) in partners.iter() { deck_partners.entry(partner.deck) .or_insert_with(Vec::new) .push((entity, identity)); } // For each deck with partners for (deck_entity, partners) in deck_partners.iter() { if let Ok(deck) = decks.get(*deck_entity) { // Calculate combined identity let mut combined = ColorIdentity::colorless(); for (_, identity) in partners.iter() { combined.white |= identity.white; combined.blue |= identity.blue; combined.black |= identity.black; combined.red |= identity.red; combined.green |= identity.green; } // Store combined identity for deck validation // ... } } } }
Five-Color Commanders
Five-color commanders like "The Ur-Dragon" allow any card in the deck:
#![allow(unused)] fn main() { impl ColorIdentity { /// Checks if this is a five-color identity pub fn is_five_color(&self) -> bool { self.white && self.blue && self.black && self.red && self.green } } }
UI Representation
Color identity is visually represented in the UI:
- The commander's color identity is displayed in the command zone
- During deck construction, cards that don't match the commander's color identity are highlighted
- Card browser filters can be set to only show cards matching the commander's color identity
- Color identity is shown as colored pips in card displays
Testing
Example Tests
#![allow(unused)] fn main() { #[test] fn test_color_identity_calculation() { // Test basic mana cost identity let cost = Mana::new(0, 1, 1, 0, 0, 0); // WU let identity = calculate_color_identity(&cost, "", &CardTypes::default()); assert!(identity.white); assert!(identity.blue); assert!(!identity.black); assert!(!identity.red); assert!(!identity.green); // Test rules text identity let cost = Mana::new(0, 0, 0, 0, 0, 0); // Colorless let text = "Add {R} or {G} to your mana pool."; let identity = calculate_color_identity(&cost, text, &CardTypes::default()); assert!(!identity.white); assert!(!identity.blue); assert!(!identity.black); assert!(identity.red); assert!(identity.green); // Test hybrid mana let cost = Mana::new(0, 0, 0, 0, 0, 0); // Colorless let text = "This spell costs {W/B} less to cast."; let identity = calculate_color_identity(&cost, text, &CardTypes::default()); assert!(identity.white); assert!(!identity.blue); assert!(identity.black); assert!(!identity.red); assert!(!identity.green); } #[test] fn test_deck_validation() { // Set up a test environment let mut app = App::new(); app.add_systems(Update, validate_deck_color_identity) .add_event::<DeckValidationEvent>(); // Create a Simic (Green-Blue) commander let commander_entity = app.world.spawn(( Commander { deck: Entity::from_raw(1), ..Default::default() }, ColorIdentity::new(false, true, false, false, true), // Blue-Green )).id(); // Create a deck with the commander and some cards let deck_entity = Entity::from_raw(1); // Valid cards let valid_card1 = app.world.spawn(( InDeck { deck: deck_entity }, ColorIdentity::new(false, true, false, false, false), // Blue only )).id(); let valid_card2 = app.world.spawn(( InDeck { deck: deck_entity }, ColorIdentity::new(false, false, false, false, true), // Green only )).id(); // Invalid card (contains red) let invalid_card = app.world.spawn(( InDeck { deck: deck_entity }, ColorIdentity::new(false, true, false, true, true), // Blue-Red-Green )).id(); // Run validation app.update(); // Check validation results let events = app.world.resource::<Events<DeckValidationEvent>>(); let mut reader = events.get_reader(); let mut invalid_cards = Vec::new(); for event in reader.read(&events) { if let DeckValidationEvent::InvalidCard { card, .. } = event { invalid_cards.push(*card); } } assert_eq!(invalid_cards.len(), 1); assert_eq!(invalid_cards[0], invalid_card); } }
Summary
Color identity in Commander is implemented as a comprehensive system that:
- Correctly calculates color identity from all relevant card components
- Enforces deck construction rules based on the commander's color identity
- Supports special cases like partner commanders and colorless commanders
- Provides clear visual feedback in the UI
- Is thoroughly tested with both unit and integration tests
Commander-Specific Zone Mechanics
Overview
This section covers the implementation of Commander-specific zone mechanics in the Rummage game engine. For core zone mechanics that apply to all formats, see the MTG Core Zones documentation.
Commander Zone Extensions
Commander extends the basic MTG zone system by:
- Adding the Command Zone - A special zone where commanders begin the game
- Modifying Zone Transition Rules - Commanders can optionally move to the command zone instead of other zones
- Introducing Commander Tax - Additional mana cost for casting commanders from the command zone
Contents
- Command Zone - Implementation of the command zone and commander-specific mechanics
- Zone Transitions - Special rules for commander movement between zones
- Zone Management - Extended zone implementation for Commander games
Key Commander Zone Mechanics
- Commander Placement: Commanders start in the command zone before the game begins
- Zone Replacement: When a commander would be put into a library, hand, graveyard, or exile, its owner may choose to put it into the command zone instead
- Commander Tax: Each time a commander is cast from the command zone, it costs an additional {2} for each previous time it has been cast from the command zone
- Partner Commanders: Special handling for decks with two commanders (Partner mechanic)
- Commander Backgrounds: Implementation of the Background mechanic for certain commanders
These Commander-specific zone mechanics build upon the core zone system to enable the unique gameplay experience of the Commander format.
Next: Command Zone
Command Zone Management
Overview
The Command Zone is a unique game zone central to the Commander format. This module manages the Command Zone, Commander card movement between zones, and the special rules surrounding Commander cards. It integrates with the zone management and player systems to provide a complete implementation of Commander-specific mechanics according to the official Magic: The Gathering Comprehensive Rules section 903.
Core Components
Command Zone Structure
#![allow(unused)] fn main() { #[derive(Resource)] pub struct CommandZoneManager { // Maps player entity to their commander entities in the command zone pub command_zones: HashMap<Entity, Vec<Entity>>, // Tracks whether commanders are in the command zone or elsewhere pub commander_zone_status: HashMap<Entity, CommanderZoneLocation>, // Tracks the number of times each commander has been cast from the command zone pub cast_count: HashMap<Entity, u32>, // Tracks commanders that died/were exiled this turn (for state-based actions) pub died_this_turn: HashSet<Entity>, pub exiled_this_turn: HashSet<Entity>, // Track partner commanders and backgrounds pub commander_partnerships: HashMap<Entity, Entity>, pub backgrounds: HashMap<Entity, Entity>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CommanderZoneLocation { CommandZone, Battlefield, Graveyard, Exile, Library, Hand, Stack, Limbo, // Transitional state } }
Commander Components
#![allow(unused)] fn main() { #[derive(Component)] pub struct Commander { pub owner: Entity, pub partner: Option<Entity>, pub background: Option<Entity>, pub color_identity: ColorIdentity, } #[derive(Component)] pub struct CommanderCastable { pub base_cost: ManaCost, pub current_tax: u32, } }
Key Systems
Command Zone Initialization
#![allow(unused)] fn main() { pub fn initialize_command_zone( mut commands: Commands, mut command_zone: ResMut<CommandZoneManager>, players: Query<Entity, With<Player>>, commanders: Query<(Entity, &Commander)>, ) { // Create empty command zone entries for each player for player in players.iter() { command_zone.command_zones.insert(player, Vec::new()); } // Place all commanders in their owners' command zones for (entity, commander) in commanders.iter() { if let Some(zone_list) = command_zone.command_zones.get_mut(&commander.owner) { zone_list.push(entity); } // Set initial zone status command_zone.commander_zone_status.insert(entity, CommanderZoneLocation::CommandZone); // Initialize cast count for commander tax command_zone.cast_count.insert(entity, 0); // Add castable component with initial tax of zero if let Ok(card) = commanders.get_component::<Card>(entity) { commands.entity(entity).insert(CommanderCastable { base_cost: card.cost.clone(), current_tax: 0, }); } } // Set up partner relationships for (entity, commander) in commanders.iter() { if let Some(partner) = commander.partner { command_zone.commander_partnerships.insert(entity, partner); } if let Some(background) = commander.background { command_zone.backgrounds.insert(entity, background); } } } }
Commander Casting System
#![allow(unused)] fn main() { pub fn handle_commander_cast( mut commands: Commands, mut command_zone: ResMut<CommandZoneManager>, mut cast_events: EventReader<CastFromCommandZoneEvent>, mut castable: Query<&mut CommanderCastable>, ) { for event in cast_events.read() { let commander_entity = event.commander; // Update zone status command_zone.commander_zone_status.insert(commander_entity, CommanderZoneLocation::Stack); // Increment cast count for commander tax if let Some(count) = command_zone.cast_count.get_mut(&commander_entity) { *count += 1; // Update tax amount for next cast if let Ok(mut commander_castable) = castable.get_mut(commander_entity) { commander_castable.current_tax = *count * 2; // 2 mana per previous cast } } // Remove from command zone list if let Some(player_zone) = command_zone.command_zones.get_mut(&event.player) { if let Some(pos) = player_zone.iter().position(|&c| c == commander_entity) { player_zone.swap_remove(pos); } } } } }
Zone Transition Handler
#![allow(unused)] fn main() { pub fn handle_commander_zone_transitions( mut commands: Commands, mut command_zone: ResMut<CommandZoneManager>, mut zone_events: EventReader<ZoneTransitionEvent>, mut choice_events: EventWriter<CommanderZoneChoiceEvent>, ) { for event in zone_events.read() { // Only process events for commander entities if !command_zone.commander_zone_status.contains_key(&event.entity) { continue; } let destination = match event.destination { Zone::Graveyard => CommanderZoneLocation::Graveyard, Zone::Exile => CommanderZoneLocation::Exile, Zone::Library => CommanderZoneLocation::Library, Zone::Hand => CommanderZoneLocation::Hand, Zone::Battlefield => CommanderZoneLocation::Battlefield, Zone::Stack => CommanderZoneLocation::Stack, // Handle other zones... _ => continue, }; // Record death/exile for state-based actions if destination == CommanderZoneLocation::Graveyard { command_zone.died_this_turn.insert(event.entity); } else if destination == CommanderZoneLocation::Exile { command_zone.exiled_this_turn.insert(event.entity); } // Update commander location command_zone.commander_zone_status.insert(event.entity, destination); // If moving to graveyard, exile, library, or hand, offer replacement to command zone if matches!(destination, CommanderZoneLocation::Graveyard | CommanderZoneLocation::Exile | CommanderZoneLocation::Library | CommanderZoneLocation::Hand) { // Find owner let owner = commanders.get_component::<Commander>(event.entity) .map(|c| c.owner) .unwrap_or(event.controller); // Send choice event to owner choice_events.send(CommanderZoneChoiceEvent { commander: event.entity, owner, from_zone: destination, }); } } } }
Command Zone API
The Command Zone module provides a public API for other modules to interact with commander-specific functionality:
#![allow(unused)] fn main() { impl CommandZoneManager { // Get the current tax for a commander pub fn get_commander_tax(&self, commander: Entity) -> u32 { self.cast_count.get(&commander).copied().unwrap_or(0) * 2 } // Check if an entity is a commander pub fn is_commander(&self, entity: Entity) -> bool { self.commander_zone_status.contains_key(&entity) } // Move a commander to the command zone pub fn move_to_command_zone(&mut self, commander: Entity, owner: Entity) { // Update status self.commander_zone_status.insert(commander, CommanderZoneLocation::CommandZone); // Add to command zone list if let Some(zone) = self.command_zones.get_mut(&owner) { if !zone.contains(&commander) { zone.push(commander); } } } // Get all commanders for a player pub fn get_player_commanders(&self, player: Entity) -> Vec<Entity> { self.command_zones.get(&player) .cloned() .unwrap_or_default() } } }
The Command Zone management module is central to the Commander format, as it implements the unique rules that define the format, including the command zone, commander casting with tax, and zone transition replacements.
Commander Exile Zone
This section documents the implementation of the Exile Zone in the Commander format.
Overview
The Exile Zone in Magic: The Gathering represents a special area where cards are removed from the game. In Commander, there are specific interactions between the Exile Zone and the Command Zone that merit special attention.
Implementation Details
Exile Zone Component
#![allow(unused)] fn main() { #[derive(Component)] pub struct ExileZone { pub contents: Vec<Entity>, } }
Commander-Specific Exile Interactions
When a commander would be exiled, the owner can choose to move it to the Command Zone instead. This is implemented through a player choice event:
#![allow(unused)] fn main() { fn handle_commander_exile( mut commands: Commands, mut exile_events: EventReader<ExileEvent>, commanders: Query<(Entity, &Commander)>, mut choice_events: EventWriter<PlayerChoiceEvent>, ) { for event in exile_events.iter() { if let Ok((entity, commander)) = commanders.get(event.card) { // Give player the choice to move to command zone instead choice_events.send(PlayerChoiceEvent { player: commander.owner, choices: vec![ PlayerChoice::MoveToExile, PlayerChoice::MoveToCommandZone, ], context: ChoiceContext::CommanderExile { commander: entity }, }); } } } }
Exile-Based Effects
Many Commander cards interact with the Exile Zone in specific ways:
- Temporary Exile: Effects that exile cards until certain conditions are met
- Exile as Resource: Effects that use exiled cards for special abilities
- Commander Reprocessing: When commanders return from exile, they reset any temporary effects
Testing
The Exile Zone implementation is tested with these scenarios:
#![allow(unused)] fn main() { #[test] fn test_commander_exile_choice() { // Test setup let mut app = App::new(); app.add_plugins(CommanderPlugin); // Create test entities let player = app.world.spawn_empty().id(); let commander = app.world.spawn(( Commander { owner: player, cast_count: 0 }, Card::default(), )).id(); // Trigger exile event app.world.resource_mut::<Events<ExileEvent>>().send(ExileEvent { card: commander, source: None, }); // Run systems app.update(); // Verify player received choice let choices = app.world.resource::<Events<PlayerChoiceEvent>>() .get_reader() .iter(app.world.resource::<Events<PlayerChoiceEvent>>()) .collect::<Vec<_>>(); assert!(!choices.is_empty()); // Additional assertions... } }
Integration with Other Zones
The Exile Zone interacts with other zones in specific ways:
- Command Zone: Commander replacement effect
- Battlefield: "Until end of turn" exile effects
- Graveyard: "Exile from graveyard" effects common in Commander
Next Steps
This page is part of the Game Zones documentation for the Commander format.
Commander Zone Transitions
Overview
Zone transitions are a critical aspect of the Commander format, particularly with the special replacement effect that allows commanders to return to the Command Zone instead of going to certain other zones. This document details the implementation of these zone transition rules according to Magic: The Gathering Comprehensive Rules section 903.9.
Commander Zone Transition Rules
According to the rules:
- If a commander would be exiled or put into a hand, graveyard, or library from anywhere, its owner may choose to put it into the command zone instead. This is a replacement effect.
- The choice is made as the zone change would occur.
- If a commander is moved directly to the command zone in this way, its last known information is used to determine what happened to it.
Implementation Components
Zone Transition Events
#![allow(unused)] fn main() { #[derive(Event)] pub struct ZoneTransitionEvent { pub entity: Entity, pub controller: Entity, pub source: Zone, pub destination: Zone, pub reason: ZoneChangeReason, } #[derive(Event)] pub struct CommanderZoneChoiceEvent { pub commander: Entity, pub owner: Entity, pub from_zone: CommanderZoneLocation, } #[derive(Event)] pub struct CommanderZoneChoiceResponseEvent { pub commander: Entity, pub owner: Entity, pub choose_command_zone: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ZoneChangeReason { Cast, Resolve, PutOntoBattlefield, Destroy, Sacrifice, Exile, ReturnToHand, PutIntoGraveyard, PutIntoLibrary, PhasingOut, PhasingIn, StateBasedAction, Replacement, Other, } }
Zone Transition System
#![allow(unused)] fn main() { pub fn handle_commander_zone_choice_responses( mut commands: Commands, mut command_zone: ResMut<CommandZoneManager>, mut response_events: EventReader<CommanderZoneChoiceResponseEvent>, mut state_actions: EventWriter<StateBasedActionEvent>, ) { for event in response_events.read() { if event.choose_command_zone { // Update commander location to Command Zone command_zone.commander_zone_status.insert(event.commander, CommanderZoneLocation::CommandZone); // Add to command zone list if not already there if let Some(zone) = command_zone.command_zones.get_mut(&event.owner) { if !zone.contains(&event.commander) { zone.push(event.commander); } } // Trigger any state-based actions that might care about this state_actions.send(StateBasedActionEvent::CommanderZoneChanged(event.commander)); } } } }
Zone Transition Logic
The zone transition system follows this logical flow:
- A card is about to change zones (triggered by a game event)
- The system checks if the card is a commander
- If it is, and the destination is hand, graveyard, library, or exile: a. A choice event is sent to the owner b. The game may need to wait for user input (or AI decision) c. The owner chooses whether to redirect to command zone
- Based on the choice, the card either goes to the original destination or the command zone
- State-based actions are triggered to handle any consequences
Zone Transition Diagram
+-----------------+
| Zone Change |
| Triggered |
+--------+--------+
|
+--------v--------+
| Is it a |
No | Commander? | Yes
+--------+ +--------+
| +-----------------+ |
| |
+---------v---------+ +---------v---------+
| Normal Zone | | Destination GY, | No
| Change Processing | | Hand, Library, +--------+
+-------------------+ | or Exile? | |
+---------+---------+ |
| |
| Yes |
+---------v---------+ +-----v------+
| Send Choice Event | | Normal Zone|
| to Owner | | Change |
+---------+---------+ +------------+
|
+-----------+ | +------------+
| | | | |
+------v------+ | | +-v-----------v--+
| Choose | | | | Choose Original |
| Command Zone|<---+----+->| Destination |
+------+------+ | +--------+--------+
| | |
+-------------v-------------+ | +--------v---------+
| Move to Command Zone | | | Move to Original |
| Update Status | | | Destination |
| Trigger State-Based | | | Update Status |
| Actions | | | Trigger Effects |
+---------------------------+ | +------------------+
|
v
+-------------------+
| Continue Game |
| Processing |
+-------------------+
Special Cases
Death Triggers
When a commander would die (go to the graveyard from the battlefield), it can create complications with death triggers:
#![allow(unused)] fn main() { pub fn handle_commander_death_triggers( command_zone: Res<CommandZoneManager>, mut death_events: EventReader<DeathEvent>, mut death_triggers: EventWriter<TriggerEvent>, ) { for event in death_events.read() { let entity = event.entity; // Check if this is a commander that was redirected to the command zone if command_zone.is_commander(entity) && command_zone.commander_zone_status.get(&entity) == Some(&CommanderZoneLocation::CommandZone) && command_zone.died_this_turn.contains(&entity) { // Create death trigger events even though it went to command zone death_triggers.send(TriggerEvent::Death { entity, from_battlefield: true, redirected_to_command_zone: true, }); } } } }
Multiple Zone Changes
If a commander would quickly change zones multiple times, each transition is handled separately:
#![allow(unused)] fn main() { pub fn handle_rapid_zone_changes( mut command_zone: ResMut<CommandZoneManager>, mut transition_events: EventReader<ZoneTransitionEvent>, mut choice_events: EventWriter<CommanderZoneChoiceEvent>, ) { // Track entities that have already had a choice offered this update let mut already_processed = HashSet::new(); for event in transition_events.read() { // Skip non-commander entities if !command_zone.is_commander(event.entity) { continue; } // Skip if we already processed this entity this frame if already_processed.contains(&event.entity) { continue; } // Process as normal... // Mark as processed already_processed.insert(event.entity); } } }
The zone transition system is one of the most complex parts of the Commander implementation, as it needs to handle the unique replacement effects that define the format while preserving proper triggers and game state.
Zone Management in Commander
Overview
Zone management in Commander follows the standard Magic: The Gathering zone structure but with special considerations for the Command Zone and commander-specific zone transitions. This document covers the implementation of zone management in the Commander format.
Game Zones
Commander uses all standard Magic: The Gathering zones plus the Command Zone:
#![allow(unused)] fn main() { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Zone { Battlefield, Stack, Hand, Library, Graveyard, Exile, Command, Limbo, // Transitional zone } }
Zone Manager Implementation
The Zone Manager keeps track of all entities in each zone:
#![allow(unused)] fn main() { #[derive(Resource)] pub struct ZoneManager { // Maps zone type to a set of entities in that zone pub zones: HashMap<Zone, HashSet<Entity>>, // Maps entity to its current zone pub entity_zones: HashMap<Entity, Zone>, // Maps player to their personal zones (hand, library, etc.) pub player_zones: HashMap<Entity, HashMap<Zone, HashSet<Entity>>>, } }
Key Systems
Zone Initialization
#![allow(unused)] fn main() { pub fn initialize_zones( mut commands: Commands, mut zone_manager: ResMut<ZoneManager>, players: Query<Entity, With<Player>>, ) { // Initialize global zones zone_manager.zones.insert(Zone::Battlefield, HashSet::new()); zone_manager.zones.insert(Zone::Stack, HashSet::new()); zone_manager.zones.insert(Zone::Exile, HashSet::new()); zone_manager.zones.insert(Zone::Command, HashSet::new()); zone_manager.zones.insert(Zone::Limbo, HashSet::new()); // Initialize player-specific zones for player in players.iter() { let mut player_zones = HashMap::new(); player_zones.insert(Zone::Hand, HashSet::new()); player_zones.insert(Zone::Library, HashSet::new()); player_zones.insert(Zone::Graveyard, HashSet::new()); zone_manager.player_zones.insert(player, player_zones); } } }
Zone Transition System
#![allow(unused)] fn main() { pub fn handle_zone_transitions( mut commands: Commands, mut zone_manager: ResMut<ZoneManager>, mut events: EventReader<ZoneTransitionEvent>, mut commander_events: EventWriter<CommanderZoneTransitionEvent>, command_zone: Res<CommandZoneManager>, ) { for event in events.read() { let entity = event.entity; let controller = event.controller; let source = event.source; let destination = event.destination; // Check if entity is a commander for special handling let is_commander = command_zone.is_commander(entity); // Special case for commander - emit commander-specific event if is_commander { commander_events.send(CommanderZoneTransitionEvent { commander: entity, controller, source, destination, reason: event.reason, }); // Let the commander system handle it continue; } // Normal zone transition handling handle_normal_zone_transition( &mut zone_manager, entity, controller, source, destination ); } } fn handle_normal_zone_transition( zone_manager: &mut ZoneManager, entity: Entity, controller: Entity, source: Zone, destination: Zone, ) { // Remove from source zone match source { Zone::Battlefield | Zone::Stack | Zone::Exile | Zone::Command | Zone::Limbo => { if let Some(zone_entities) = zone_manager.zones.get_mut(&source) { zone_entities.remove(&entity); } }, Zone::Hand | Zone::Library | Zone::Graveyard => { if let Some(player_zones) = zone_manager.player_zones.get_mut(&controller) { if let Some(zone_entities) = player_zones.get_mut(&source) { zone_entities.remove(&entity); } } } } // Add to destination zone match destination { Zone::Battlefield | Zone::Stack | Zone::Exile | Zone::Command | Zone::Limbo => { if let Some(zone_entities) = zone_manager.zones.get_mut(&destination) { zone_entities.insert(entity); } }, Zone::Hand | Zone::Library | Zone::Graveyard => { if let Some(player_zones) = zone_manager.player_zones.get_mut(&controller) { if let Some(zone_entities) = player_zones.get_mut(&destination) { zone_entities.insert(entity); } } } } // Update entity's current zone zone_manager.entity_zones.insert(entity, destination); } }
Command Zone Integration
The Zone Manager integrates with the Command Zone manager to handle special Commander-specific zone transitions:
#![allow(unused)] fn main() { pub fn sync_command_zone( mut zone_manager: ResMut<ZoneManager>, command_zone: Res<CommandZoneManager>, ) { // Get all entities currently in the command zone let mut command_zone_entities = HashSet::new(); for player_commanders in command_zone.command_zones.values() { for commander in player_commanders.iter() { command_zone_entities.insert(*commander); } } // Update the zone manager's command zone if let Some(zone_entities) = zone_manager.zones.get_mut(&Zone::Command) { *zone_entities = command_zone_entities; } // Update entity_zones for all commanders for (commander, location) in command_zone.commander_zone_status.iter() { if *location == CommanderZoneLocation::CommandZone { zone_manager.entity_zones.insert(*commander, Zone::Command); } } } }
Special Commander Zone Considerations
Casting From Command Zone
#![allow(unused)] fn main() { pub fn enable_command_zone_casting( mut commands: Commands, zone_manager: Res<ZoneManager>, command_zone: Res<CommandZoneManager>, mut players: Query<(Entity, &mut PlayerActions)>, ) { for (player, mut actions) in players.iter_mut() { // Get player's commanders in the command zone let commanders = command_zone .command_zones .get(&player) .cloned() .unwrap_or_default(); // Add castable action for each commander for commander in commanders { if let Some(tax) = command_zone.cast_count.get(&commander) { actions.castable_from_command_zone.insert( commander, *tax * 2 // 2 mana per previous cast ); } } } } }
Zone Visibility
Zone visibility in Commander follows standard MTG rules with players only seeing their own hands and libraries:
#![allow(unused)] fn main() { pub fn update_zone_visibility( mut commands: Commands, zone_manager: Res<ZoneManager>, players: Query<Entity, With<Player>>, ) { for player in players.iter() { // Add components for visible zones let mut visible_entities = HashSet::new(); // Public zones (visible to all) for zone in [Zone::Battlefield, Zone::Stack, Zone::Exile, Zone::Command].iter() { if let Some(zone_entities) = zone_manager.zones.get(zone) { visible_entities.extend(zone_entities.iter()); } } // All players' graveyards are public for (other_player, zones) in zone_manager.player_zones.iter() { if let Some(graveyard) = zones.get(&Zone::Graveyard) { visible_entities.extend(graveyard.iter()); } } // Player's own hand and library (top card only if revealed) if let Some(player_zones) = zone_manager.player_zones.get(&player) { if let Some(hand) = player_zones.get(&Zone::Hand) { visible_entities.extend(hand.iter()); } // Other private zone logic... } // Update visibility components commands.entity(player).insert(ZoneVisibility { visible_entities: visible_entities.into_iter().collect(), }); } } }
The Zone Management system provides a complete implementation of Magic: The Gathering zones with special handling for Commander-specific mechanics, particularly related to the Command Zone and commander movement between zones.
Zone Tests
This section contains tests related to the various zone mechanics in the Commander format.
Command Zone Tests
The Command Zone Tests verify the proper behavior of the Command Zone and related mechanics, such as:
- Commander movement to and from the command zone
- Commander tax calculation
- Proper tracking of command zone events
- Integration with state-based actions
These tests ensure that the unique Commander zone mechanics function correctly within the game engine.
Command Zone Tests
Overview
This document outlines test cases for the Command Zone in the Commander format. These tests ensure correct implementation of all Commander-specific rules related to the Command Zone, commander casting, commander tax, and zone transitions.
Test Case: Commander Casting
Test: Initial Commander Cast from Command Zone
#![allow(unused)] fn main() { #[test] fn test_initial_commander_cast() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (check_commander_cast, update_command_zone)); // Create player and their commander let player = app.world.spawn(( Player {}, Mana::default(), ActivePlayer, )).id(); let commander = app.world.spawn(( Card { name: "Test Commander".to_string(), cost: ManaCost::from_string("{2}{G}{G}").unwrap(), }, Commander { owner: player, cast_count: 0 }, Zone::CommandZone, )).id(); // Player has enough mana to cast let mut player_mana = app.world.get_mut::<Mana>(player).unwrap(); player_mana.add(ManaType::Green, 2); player_mana.add(ManaType::Colorless, 2); // Attempt to cast commander app.world.send_event(CastSpellEvent { caster: player, card: commander, }); app.update(); // Verify commander moved to stack assert_eq!(app.world.get::<Zone>(commander).unwrap(), &Zone::Stack); // Verify no additional tax was applied (first cast) assert_eq!(app.world.get::<Commander>(commander).unwrap().cast_count, 1); } }
Test: Recasting Commander with Commander Tax
#![allow(unused)] fn main() { #[test] fn test_commander_tax() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (check_commander_cast, update_command_zone, apply_commander_tax)); // Create player and their commander let player = app.world.spawn(( Player {}, Mana::default(), ActivePlayer, )).id(); let commander = app.world.spawn(( Card { name: "Test Commander".to_string(), cost: ManaCost::from_string("{2}{G}{G}").unwrap(), }, Commander { owner: player, cast_count: 1 }, // Already cast once before Zone::CommandZone, )).id(); // Player has enough mana to cast with tax let mut player_mana = app.world.get_mut::<Mana>(player).unwrap(); player_mana.add(ManaType::Green, 2); player_mana.add(ManaType::Colorless, 4); // 2 original + 2 tax // Attempt to cast commander app.world.send_event(CastSpellEvent { caster: player, card: commander, }); app.update(); // Verify commander moved to stack assert_eq!(app.world.get::<Zone>(commander).unwrap(), &Zone::Stack); // Verify cast count was incremented assert_eq!(app.world.get::<Commander>(commander).unwrap().cast_count, 2); // Verify mana was properly deducted including tax let player_mana = app.world.get::<Mana>(player).unwrap(); assert_eq!(player_mana.get(ManaType::Green), 0); assert_eq!(player_mana.get(ManaType::Colorless), 0); } }
Test: Insufficient Mana for Commander Tax
#![allow(unused)] fn main() { #[test] fn test_insufficient_mana_for_commander_tax() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (check_commander_cast, update_command_zone, apply_commander_tax)); // Create player and their commander let player = app.world.spawn(( Player {}, Mana::default(), ActivePlayer, )).id(); let commander = app.world.spawn(( Card { name: "Test Commander".to_string(), cost: ManaCost::from_string("{2}{G}{G}").unwrap(), }, Commander { owner: player, cast_count: 2 }, // Already cast twice before Zone::CommandZone, )).id(); // Player doesn't have enough mana for tax let mut player_mana = app.world.get_mut::<Mana>(player).unwrap(); player_mana.add(ManaType::Green, 2); player_mana.add(ManaType::Colorless, 3); // 2 original + 4 tax needed, only 3 available // Attempt to cast commander app.world.send_event(CastSpellEvent { caster: player, card: commander, }); app.update(); // Verify commander stayed in command zone assert_eq!(app.world.get::<Zone>(commander).unwrap(), &Zone::CommandZone); // Verify cast count was not incremented assert_eq!(app.world.get::<Commander>(commander).unwrap().cast_count, 2); } }
Test Case: Zone Transitions
Test: Commander Dies and Goes to Command Zone
#![allow(unused)] fn main() { #[test] fn test_commander_death_to_command_zone() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (handle_zone_transitions, commander_replacement_effects)); // Create player and their commander let player = app.world.spawn(( Player {}, CommanderZoneChoice { use_command_zone: true }, )).id(); let commander = app.world.spawn(( Card { name: "Test Commander".to_string() }, Commander { owner: player, cast_count: 1 }, Zone::Battlefield, )).id(); // Trigger death of commander app.world.send_event(ZoneChangeEvent { entity: commander, from: Zone::Battlefield, to: Zone::Graveyard, cause: ZoneChangeCause::Death, }); app.update(); // Verify commander went to command zone instead of graveyard assert_eq!(app.world.get::<Zone>(commander).unwrap(), &Zone::CommandZone); } }
Test: Commander Goes to Exile and Replaced to Command Zone
#![allow(unused)] fn main() { #[test] fn test_commander_exile_to_command_zone() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (handle_zone_transitions, commander_replacement_effects)); // Create player and their commander let player = app.world.spawn(( Player {}, CommanderZoneChoice { use_command_zone: true }, )).id(); let commander = app.world.spawn(( Card { name: "Test Commander".to_string() }, Commander { owner: player, cast_count: 1 }, Zone::Battlefield, )).id(); // Trigger exile of commander app.world.send_event(ZoneChangeEvent { entity: commander, from: Zone::Battlefield, to: Zone::Exile, cause: ZoneChangeCause::Exile, }); app.update(); // Verify commander went to command zone instead of exile assert_eq!(app.world.get::<Zone>(commander).unwrap(), &Zone::CommandZone); } }
Test: Commander Goes to Hand by Player Choice
#![allow(unused)] fn main() { #[test] fn test_commander_to_hand_by_choice() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (handle_zone_transitions, commander_replacement_effects)); // Create player and their commander let player = app.world.spawn(( Player {}, CommanderZoneChoice { use_command_zone: false }, // Choose not to use command zone )).id(); let commander = app.world.spawn(( Card { name: "Test Commander".to_string() }, Commander { owner: player, cast_count: 1 }, Zone::Battlefield, )).id(); // Trigger bounce of commander app.world.send_event(ZoneChangeEvent { entity: commander, from: Zone::Battlefield, to: Zone::Hand, cause: ZoneChangeCause::ReturnToHand, }); app.update(); // Verify commander went to hand as chosen assert_eq!(app.world.get::<Zone>(commander).unwrap(), &Zone::Hand); } }
Test Case: Partner Commanders
Test: Two Partner Commanders in Command Zone
#![allow(unused)] fn main() { #[test] fn test_partner_commanders() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (check_command_zone_validity, handle_partner_mechanics)); // Create player let player = app.world.spawn(( Player {}, CommanderList::default(), )).id(); // Create two partner commanders let commander1 = app.world.spawn(( Card { name: "Partner Commander 1".to_string() }, Commander { owner: player, cast_count: 0 }, PartnerAbility, Zone::CommandZone, )).id(); let commander2 = app.world.spawn(( Card { name: "Partner Commander 2".to_string() }, Commander { owner: player, cast_count: 0 }, PartnerAbility, Zone::CommandZone, )).id(); // Add commanders to player's commander list let mut commander_list = app.world.get_mut::<CommanderList>(player).unwrap(); commander_list.add(commander1); commander_list.add(commander2); app.update(); // Verify player can have two commanders because they have partner let validation_errors = app.world.resource::<ValidationErrors>(); assert!(validation_errors.is_empty()); // Verify both commanders are in the command zone assert_eq!(app.world.get::<Zone>(commander1).unwrap(), &Zone::CommandZone); assert_eq!(app.world.get::<Zone>(commander2).unwrap(), &Zone::CommandZone); } }
Test Case: Command Zone Visibility
Test: Command Zone Cards Visible to All Players
#![allow(unused)] fn main() { #[test] fn test_command_zone_visibility() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, check_card_visibility); // Create multiple players let player1 = app.world.spawn(Player {}).id(); let player2 = app.world.spawn(Player {}).id(); let player3 = app.world.spawn(Player {}).id(); // Create a commander in the command zone let commander = app.world.spawn(( Card { name: "Test Commander".to_string() }, Commander { owner: player1, cast_count: 0 }, Zone::CommandZone, Visibility::default(), )).id(); // Update visibility app.update(); // Verify all players can see the commander in command zone let visibility = app.world.get::<Visibility>(commander).unwrap(); assert!(visibility.is_visible_to(player1)); assert!(visibility.is_visible_to(player2)); assert!(visibility.is_visible_to(player3)); } }
Test Case: Commander Identity Restrictions
Test: Card Color Identity Validation for Commander Deck
#![allow(unused)] fn main() { #[test] fn test_color_identity_restrictions() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, validate_deck_color_identity); // Create player and commander let player = app.world.spawn(Player {}).id(); let commander = app.world.spawn(( Card { name: "Mono-Green Commander".to_string() }, Commander { owner: player, cast_count: 0 }, ColorIdentity { colors: vec![Color::Green] }, )).id(); // Create legal and illegal cards for the deck let legal_card = app.world.spawn(( Card { name: "Green Card".to_string() }, ColorIdentity { colors: vec![Color::Green] }, InDeck { owner: player }, )).id(); let illegal_card = app.world.spawn(( Card { name: "Blue Card".to_string() }, ColorIdentity { colors: vec![Color::Blue] }, InDeck { owner: player }, )).id(); // Create deck with the commander app.world.spawn(( Deck { owner: player }, CommanderDeck { commander: commander }, Cards { entities: vec![legal_card, illegal_card] }, )); // Validate deck app.update(); // Verify validation errors for illegal card let validation_errors = app.world.resource::<ValidationErrors>(); assert!(!validation_errors.is_empty()); assert!(validation_errors.contains_error_about(illegal_card, "color identity")); } }
These test cases provide comprehensive coverage of the Command Zone mechanics and ensure that all the format-specific rules are correctly implemented and enforced.
Turns and Phases
This section covers the implementation of turn structure and phases in the Commander format.
Contents
- Turn Structure - Complete turn sequence implementation
- Phase Management - Handling of individual phases and steps
- Priority System - Priority passing within phases
- Extra Turns & Modifications - Extra turns and turn modification effects
- Multiplayer Turn Handling - Multiplayer-specific turn considerations
The turns and phases section defines how the turn structure of Commander games is implemented, with special consideration for multiplayer dynamics:
- Standard Magic turn structure (untap, upkeep, draw, main phases, combat, etc.)
- Multiplayer turn order determination and rotation
- Special handling for simultaneous player actions
- Turn-based effects in multiplayer contexts
- Turn modification effects (extra turns, skipped phases, etc.)
While the basic turn structure in Commander follows standard Magic rules, the multiplayer nature of the format introduces complexity in turn management, particularly around priority passing and simultaneous effects.
Turn Structure
Overview
The Turn Structure module manages the flow of a Commander game, handling the sequence of phases and steps within each player's turn. It coordinates player transitions, priority passing, and phase-specific actions while accounting for the multiplayer nature of Commander.
Core Turn Sequence
A turn in Commander follows the standard Magic: The Gathering sequence:
-
Beginning Phase
- Untap Step
- Upkeep Step
- Draw Step
-
Precombat Main Phase
-
Combat Phase
- Beginning of Combat Step
- Declare Attackers Step
- Declare Blockers Step
- Combat Damage Step
- End of Combat Step
-
Postcombat Main Phase
-
Ending Phase
- End Step
- Cleanup Step
Multiplayer Considerations
In Commander, turn order proceeds clockwise from the starting player. The format introduces special considerations:
- Turn Order Determination: Typically random at the start of the game
- Player Elimination: When a player loses, turns continue with the remaining players
- Extra Turns: Cards that grant extra turns work the same as in standard Magic
- "Skip your next turn" effects: These follow standard Magic rules but can have significant political impact
Data Structures
The turn structure is managed through the following robust data structures:
#![allow(unused)] fn main() { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Phase { Beginning(BeginningStep), Precombat(PrecombatStep), Combat(CombatStep), Postcombat(PostcombatStep), Ending(EndingStep), } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum BeginningStep { Untap, Upkeep, Draw, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum PrecombatStep { Main, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum CombatStep { Beginning, DeclareAttackers, DeclareBlockers, FirstStrike, CombatDamage, End, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum PostcombatStep { Main, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum EndingStep { End, Cleanup, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ExtraTurnSource { Card(Entity), Ability(Entity, Entity), // (Source, Ability) Rule, } #[derive(Resource)] pub struct TurnManager { // Current turn information pub current_phase: Phase, pub active_player_index: usize, pub turn_number: u32, // Priority system pub priority_player_index: usize, pub all_players_passed: bool, pub stack_is_empty: bool, // Multiplayer tracking pub player_order: Vec<Entity>, pub extra_turns: VecDeque<(Entity, ExtraTurnSource)>, pub skipped_turns: HashSet<Entity>, // Time tracking pub time_limits: HashMap<Entity, Duration>, pub turn_started_at: Option<Instant>, // Phase history for reverting game states and debugging pub phase_history: VecDeque<(Phase, Entity)>, // Special phase tracking pub additional_phases: VecDeque<(Phase, Entity)>, pub modified_phases: HashMap<Phase, PhaseModification>, } #[derive(Debug, Clone)] pub struct PhaseModification { pub source: Entity, pub skip_priority: bool, pub skip_actions: bool, pub additional_actions: Vec<GameAction>, } }
Detailed Implementation
Phase Progression System
#![allow(unused)] fn main() { impl TurnManager { // Advances to the next phase in the turn sequence pub fn advance_phase(&mut self) -> Result<Phase, TurnError> { let previous_phase = self.current_phase; self.current_phase = match self.current_phase { Phase::Beginning(BeginningStep::Untap) => Phase::Beginning(BeginningStep::Upkeep), Phase::Beginning(BeginningStep::Upkeep) => Phase::Beginning(BeginningStep::Draw), Phase::Beginning(BeginningStep::Draw) => Phase::Precombat(PrecombatStep::Main), Phase::Precombat(PrecombatStep::Main) => Phase::Combat(CombatStep::Beginning), Phase::Combat(CombatStep::Beginning) => Phase::Combat(CombatStep::DeclareAttackers), Phase::Combat(CombatStep::DeclareAttackers) => Phase::Combat(CombatStep::DeclareBlockers), // Check if first strike damage should happen Phase::Combat(CombatStep::DeclareBlockers) => { if self.has_first_strike_creatures() { Phase::Combat(CombatStep::FirstStrike) } else { Phase::Combat(CombatStep::CombatDamage) } }, Phase::Combat(CombatStep::FirstStrike) => Phase::Combat(CombatStep::CombatDamage), Phase::Combat(CombatStep::CombatDamage) => Phase::Combat(CombatStep::End), Phase::Combat(CombatStep::End) => Phase::Postcombat(PostcombatStep::Main), Phase::Postcombat(PostcombatStep::Main) => Phase::Ending(EndingStep::End), Phase::Ending(EndingStep::End) => Phase::Ending(EndingStep::Cleanup), Phase::Ending(EndingStep::Cleanup) => { // Check for additional or extra turns self.process_turn_end()?; // The next turn's first phase Phase::Beginning(BeginningStep::Untap) } }; // Record phase change in history self.phase_history.push_back((previous_phase, self.get_active_player())); // Check if this phase should be modified or skipped if let Some(modification) = self.modified_phases.get(&self.current_phase) { if modification.skip_actions { // Skip this phase and move to the next one return self.advance_phase(); } } // Handle priority assignment for the new phase self.reset_priority_for_new_phase(); Ok(self.current_phase) } // Process the end of a turn fn process_turn_end(&mut self) -> Result<(), TurnError> { // Check if there are additional phases for this turn if let Some((phase, _)) = self.additional_phases.pop_front() { self.current_phase = phase; return Ok(()); } // Move to the next player let next_player = self.determine_next_player()?; self.active_player_index = self.player_order.iter() .position(|&p| p == next_player) .ok_or(TurnError::InvalidPlayerIndex)?; self.turn_number += 1; // Reset turn timer self.turn_started_at = Some(Instant::now()); Ok(()) } // Determine who takes the next turn fn determine_next_player(&mut self) -> Result<Entity, TurnError> { // Check for extra turns first if let Some((player, source)) = self.extra_turns.pop_front() { // Log extra turn for game record info!("Player {:?} is taking an extra turn from source {:?}", player, source); return Ok(player); } // Get the next player in turn order let next_index = (self.active_player_index + 1) % self.player_order.len(); let next_player = self.player_order[next_index]; // Check if the next player should skip their turn if self.skipped_turns.remove(&next_player) { // Record the skipped turn info!("Player {:?} is skipping their turn", next_player); // Move to the player after that self.active_player_index = next_index; return self.determine_next_player(); } Ok(next_player) } } }
Priority System
#![allow(unused)] fn main() { impl TurnManager { // Reset priority for a new phase fn reset_priority_for_new_phase(&mut self) { self.all_players_passed = false; // In most phases, active player gets priority first self.priority_player_index = self.active_player_index; // For Beginning::Untap and Ending::Cleanup, no player gets priority by default match self.current_phase { Phase::Beginning(BeginningStep::Untap) | Phase::Ending(EndingStep::Cleanup) => { self.all_players_passed = true; }, _ => {} } } // Pass priority to the next player pub fn pass_priority(&mut self) -> Result<(), TurnError> { // Calculate next player with priority let next_index = (self.priority_player_index + 1) % self.player_order.len(); // If we're back to the active player, everyone has passed if next_index == self.active_player_index { self.all_players_passed = true; // If the stack is empty and everyone has passed, move to the next phase if self.stack_is_empty { self.advance_phase()?; } else { // Resolve the top item on the stack // This would trigger a different system self.stack_is_empty = false; // This would be set by the stack system } } else { // Pass to the next player self.priority_player_index = next_index; } Ok(()) } } }
Edge Cases and Their Handling
Player Elimination During a Turn
#![allow(unused)] fn main() { impl TurnManager { // Handle a player being eliminated pub fn handle_player_elimination(&mut self, eliminated_player: Entity) -> Result<(), TurnError> { // Remove player from the turn order let index = self.player_order.iter() .position(|&p| p == eliminated_player) .ok_or(TurnError::PlayerNotFound)?; self.player_order.remove(index); // Adjust active player index if necessary if index <= self.active_player_index && self.active_player_index > 0 { self.active_player_index -= 1; } // Adjust priority player index if necessary if index <= self.priority_player_index && self.priority_player_index > 0 { self.priority_player_index -= 1; } // Remove any extra or skipped turns for this player self.extra_turns.retain(|(player, _)| *player != eliminated_player); self.skipped_turns.remove(&eliminated_player); // If the active player was eliminated if index == self.active_player_index { // Move to the next player's turn let next_player = self.determine_next_player()?; self.active_player_index = self.player_order.iter() .position(|&p| p == next_player) .ok_or(TurnError::InvalidPlayerIndex)?; // Reset to the beginning of the turn self.current_phase = Phase::Beginning(BeginningStep::Untap); self.reset_priority_for_new_phase(); } Ok(()) } } }
Handling Multiple Extra Turns
When multiple extra turns are queued, they're processed in the order they were created:
#![allow(unused)] fn main() { impl TurnManager { // Add an extra turn to the queue pub fn add_extra_turn(&mut self, player: Entity, source: ExtraTurnSource) { self.extra_turns.push_back((player, source)); } } }
Time Limits and Slow Play
#![allow(unused)] fn main() { impl TurnManager { // Check if a player has exceeded their time limit pub fn check_time_limit(&self, player: Entity) -> Result<bool, TurnError> { if let Some(limit) = self.time_limits.get(&player) { if let Some(start_time) = self.turn_started_at { return Ok(start_time.elapsed() > *limit); } } Ok(false) } } }
Verification and Testing Strategy
Unit Tests
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; // Test normal phase progression #[test] fn test_normal_phase_progression() { let mut turn_manager = setup_test_turn_manager(); // Start at Beginning::Untap assert_eq!(turn_manager.current_phase, Phase::Beginning(BeginningStep::Untap)); // Advance through all phases for _ in 0..12 { // Total number of phases/steps in a turn turn_manager.advance_phase().unwrap(); } // Should be back at Beginning::Untap for the next player assert_eq!(turn_manager.current_phase, Phase::Beginning(BeginningStep::Untap)); assert_eq!(turn_manager.active_player_index, 1); // Next player } // Test extra turn handling #[test] fn test_extra_turn() { let mut turn_manager = setup_test_turn_manager(); let player1 = turn_manager.player_order[0]; // Add an extra turn for the current player turn_manager.add_extra_turn(player1, ExtraTurnSource::Rule); // Advance through a full turn for _ in 0..12 { turn_manager.advance_phase().unwrap(); } // Should still be the same player's turn assert_eq!(turn_manager.active_player_index, 0); } // Test skipped turn #[test] fn test_skipped_turn() { let mut turn_manager = setup_test_turn_manager(); let player2 = turn_manager.player_order[1]; // Mark player 2's turn to be skipped turn_manager.skipped_turns.insert(player2); // Advance through a full turn for _ in 0..12 { turn_manager.advance_phase().unwrap(); } // Should skip to player 3 assert_eq!(turn_manager.active_player_index, 2); } // Test player elimination #[test] fn test_player_elimination() { let mut turn_manager = setup_test_turn_manager(); let player2 = turn_manager.player_order[1]; // Eliminate player 2 turn_manager.handle_player_elimination(player2).unwrap(); // Check player order is updated assert_eq!(turn_manager.player_order.len(), 3); assert!(!turn_manager.player_order.contains(&player2)); // Complete current turn for _ in 0..12 { turn_manager.advance_phase().unwrap(); } // Should skip from player 1 to player 3 (now at index 1) assert_eq!(turn_manager.active_player_index, 1); } // Test priority passing #[test] fn test_priority_passing() { let mut turn_manager = setup_test_turn_manager(); // Move to a phase where priority is assigned turn_manager.advance_phase().unwrap(); // To Beginning::Upkeep // Active player should have priority assert_eq!(turn_manager.priority_player_index, 0); // Pass priority to each player for i in 1..4 { turn_manager.pass_priority().unwrap(); assert_eq!(turn_manager.priority_player_index, i); } // After all players pass, should move to next phase turn_manager.pass_priority().unwrap(); assert_eq!(turn_manager.current_phase, Phase::Beginning(BeginningStep::Draw)); } // Setup helper fn setup_test_turn_manager() -> TurnManager { let player1 = Entity::from_raw(1); let player2 = Entity::from_raw(2); let player3 = Entity::from_raw(3); let player4 = Entity::from_raw(4); TurnManager { current_phase: Phase::Beginning(BeginningStep::Untap), active_player_index: 0, turn_number: 1, priority_player_index: 0, all_players_passed: true, stack_is_empty: true, player_order: vec![player1, player2, player3, player4], extra_turns: VecDeque::new(), skipped_turns: HashSet::new(), time_limits: HashMap::new(), turn_started_at: Some(Instant::now()), phase_history: VecDeque::new(), additional_phases: VecDeque::new(), modified_phases: HashMap::new(), } } } }
Integration Tests
#![allow(unused)] fn main() { #[cfg(test)] mod integration_tests { use super::*; use crate::game_engine::stack::Stack; use crate::game_engine::state::GameState; // Test turns and stack interaction #[test] fn test_turns_with_stack() { let mut app = App::new(); // Set up game resources app.insert_resource(setup_test_turn_manager()); app.insert_resource(Stack::default()); app.insert_resource(GameState::default()); // Add relevant systems app.add_systems(Update, ( phase_transition_system, stack_resolution_system, priority_system, )); // Simulate a turn with stack interactions app.update(); // Add a spell to the stack let mut stack = app.world.resource_mut::<Stack>(); stack.push(create_test_spell()); // Verify stack is not empty let turn_manager = app.world.resource::<TurnManager>(); assert!(!turn_manager.stack_is_empty); // Simulate priority passing and stack resolution for _ in 0..5 { app.update(); } // Stack should be empty and phase should have advanced let turn_manager = app.world.resource::<TurnManager>(); assert!(turn_manager.stack_is_empty); assert_eq!(turn_manager.current_phase, Phase::Beginning(BeginningStep::Draw)); } // Test full turn cycle with multiple players #[test] fn test_full_turn_cycle() { let mut app = App::new(); // Set up game resources app.insert_resource(setup_test_turn_manager()); // Add systems app.add_systems(Update, phase_transition_system); // Track starting player let start_player = app.world.resource::<TurnManager>().active_player_index; // Simulate a full turn cycle (all players take one turn) for _ in 0..48 { // 4 players × 12 phases app.update(); } // Should be back to starting player let turn_manager = app.world.resource::<TurnManager>(); assert_eq!(turn_manager.active_player_index, start_player); assert_eq!(turn_manager.turn_number, 5); // Starting turn + 4 players } } }
End-to-End Testing
#![allow(unused)] fn main() { #[cfg(test)] mod e2e_tests { use super::*; use crate::test_utils::setup_full_game; // Test a full game scenario #[test] fn test_game_until_winner() { let mut app = setup_full_game(4); // 4 players // Run the game until only one player remains let max_turns = 100; // Prevent infinite loop for _ in 0..max_turns { app.update(); // Check if the game has ended let turn_manager = app.world.resource::<TurnManager>(); if turn_manager.player_order.len() == 1 { break; } // Simulate random player actions based on the current phase simulate_player_actions(&mut app); } // Verify we have a winner let turn_manager = app.world.resource::<TurnManager>(); assert_eq!(turn_manager.player_order.len(), 1, "Game should end with one player remaining"); } // Test complex turn interaction with time limits #[test] fn test_time_limits() { let mut app = setup_full_game(4); // Set time limits for all players { let mut turn_manager = app.world.resource_mut::<TurnManager>(); for &player in &turn_manager.player_order { turn_manager.time_limits.insert(player, Duration::from_secs(30)); } } // Fast-forward time for current player let current_player = { let turn_manager = app.world.resource::<TurnManager>(); turn_manager.player_order[turn_manager.active_player_index] }; // Mock time advancing past the limit for testing { let mut turn_manager = app.world.resource_mut::<TurnManager>(); turn_manager.turn_started_at = Some(Instant::now() - Duration::from_secs(31)); } // Add system to check time limits app.add_systems(Update, check_time_limits_system); // Run one update app.update(); // Verify player's turn was ended due to time limit let turn_manager = app.world.resource::<TurnManager>(); assert_ne!(turn_manager.active_player_index, turn_manager.player_order.iter().position(|&p| p == current_player).unwrap()); } } }
Performance Considerations
-
The
TurnManager
uses efficient data structures to minimize overhead:HashMap
for O(1) lookup of player time limitsVecDeque
for O(1) queue operations for extra turns and phase historyHashSet
for O(1) lookup of skipped turns
-
Phase transitions are optimized to only trigger necessary game events
-
Time complexity analysis:
- Advancing phase: O(1)
- Determining next player: O(1) in most cases, O(n) worst case (all players skip)
- Priority passing: O(1)
- Player elimination: O(n) where n is the number of players
Extensibility
The design allows for easy extension with new phases or rules:
- Additional phases can be added to the
Phase
enum - Custom phase progression can be implemented by overriding the
advance_phase
method - Game variants can be supported by modifying the
TurnManager
behavior
By following this implementation approach, the turn structure will robustly handle all standard Magic: The Gathering rules as well as the specific requirements of the Commander format.
Priority System
Overview
The priority system determines which player may take game actions at any given time during a Commander game. In a multiplayer format like Commander, properly managing priority is essential for maintaining game flow and ensuring all players have appropriate opportunities to respond.
Priority Rules
- The active player receives priority at the beginning of each step or phase, except the untap and cleanup steps
- When a player has priority, they may:
- Cast a spell
- Activate an ability
- Take a special action
- Pass priority
- After a player casts a spell or activates an ability, they receive priority again
- Priority passes in turn order (clockwise) from the active player
- When all players pass priority in succession:
- If the stack is not empty, the top item on the stack resolves, then the active player gets priority
- If the stack is empty, the current step or phase ends and the game moves to the next step or phase
Implementation
The priority system is tracked within the TurnManager
resource:
#![allow(unused)] fn main() { #[derive(Resource)] pub struct TurnManager { // Priority system fields pub priority_player_index: usize, pub all_players_passed: bool, pub stack_is_empty: bool, // Step tracking for UI and timers pub step_started_at: std::time::Instant, pub auto_pass_enabled: bool, pub auto_pass_delay: std::time::Duration, // Other fields // ... } }
Special Priority Cases
Simultaneous Actions
When multiple players would take actions simultaneously:
- First, the active player performs all their actions in any order they choose
- Then, each non-active player in turn order performs their actions
Auto-Pass System
For better digital gameplay experience, the system implements an auto-pass feature:
#![allow(unused)] fn main() { pub fn check_auto_pass_system( time: Res<Time>, mut turn_manager: ResMut<TurnManager>, player_states: Query<&PlayerState>, ) { if turn_manager.auto_pass_enabled && time.elapsed_seconds() - turn_manager.step_started_at > turn_manager.auto_pass_delay { // Auto-pass for the current priority player if they have no available actions // ... } } }
Stop Button
Players can place "stops" on specific phases and steps to ensure they receive priority, even when auto-pass is enabled:
#![allow(unused)] fn main() { #[derive(Component)] pub struct PlayerStops { pub phases: HashMap<Phase, bool>, // Additional stop settings } }
UI Representation
The priority system is visually represented to players through:
- A highlighted border around the active player's avatar
- A timer indicator showing how long the current player has had priority
- Special effects when priority passes
- Visual cues for auto-pass situations
Multiplayer Considerations
In Commander, the priority system manages additional complexity:
- Tracking priority across 3-6 players
- Handling "table talk" periods during complex game states
- Supporting take-backs by mutual agreement (optional house rule)
- Managing disconnected players in digital play
Multiplayer Turns in Commander
This document explains how multiplayer turns are implemented in the Rummage game engine for the Commander format.
Multiplayer Turn Structure
Commander is designed as a multiplayer format, typically played with 3-6 players. The multiplayer turn structure follows these rules:
- Players take turns in clockwise order, starting from a randomly determined first player
- Turn order remains fixed throughout the game unless modified by card effects
- All standard turn phases occur during each player's turn
- Players can act during other players' turns when they have priority
- Some effects specifically reference "each player" or "each opponent" which have multiplayer implications
Turn Order Implementation
#![allow(unused)] fn main() { /// Resource that tracks turn order #[derive(Resource, Debug, Clone)] pub struct TurnOrder { /// List of players in turn order pub players: Vec<Entity>, /// Current player index in the players list pub current_player_index: usize, /// Direction of turn progression (1 for clockwise, -1 for counter-clockwise) pub direction: i32, } impl TurnOrder { /// Creates a new turn order with the specified players pub fn new(players: Vec<Entity>) -> Self { Self { players, current_player_index: 0, direction: 1, } } /// Gets the current player pub fn current_player(&self) -> Entity { self.players[self.current_player_index] } /// Advances to the next player's turn pub fn advance(&mut self) { let len = self.players.len() as i32; self.current_player_index = ( (self.current_player_index as i32 + self.direction).rem_euclid(len) ) as usize; } /// Reverses the turn order direction pub fn reverse(&mut self) { self.direction = -self.direction; } /// Shuffles the player order pub fn shuffle(&mut self, rng: &mut impl Rng) { self.players.shuffle(rng); // Maintain current player at index 0 after shuffle let current = self.current_player(); let current_pos = self.players.iter().position(|&p| p == current).unwrap(); self.players.swap(0, current_pos); self.current_player_index = 0; } /// Skips the next player's turn pub fn skip_next(&mut self) { self.advance(); } /// Returns an iterator over all players starting from the current player pub fn iter_from_current(&self) -> impl Iterator<Item = Entity> + '_ { let len = self.players.len(); (0..len).map(move |offset| { let index = (self.current_player_index + offset) % len; self.players[index] }) } /// Returns an iterator over all opponents of the current player pub fn iter_opponents(&self) -> impl Iterator<Item = Entity> + '_ { let current = self.current_player(); self.players.iter().copied().filter(move |&p| p != current) } } }
Turn Advancement System
#![allow(unused)] fn main() { /// System that advances to the next player's turn pub fn advance_turn( mut turn_order: ResMut<TurnOrder>, mut turn_phase: ResMut<TurnPhase>, mut turn_events: EventWriter<TurnEvent>, mut game_state: ResMut<GameState>, time: Res<Time>, ) { if game_state.current_state != GameStateType::EndOfTurn { return; } // Get current player before advancing let previous_player = turn_order.current_player(); // Advance to next player turn_order.advance(); let next_player = turn_order.current_player(); // Reset turn phase to beginning of turn *turn_phase = TurnPhase::Beginning(BeginningPhase::Untap); // Update game state game_state.current_state = GameStateType::ActiveTurn; game_state.active_player = next_player; // Send events turn_events.send(TurnEvent::TurnEnded { player: previous_player }); turn_events.send(TurnEvent::TurnBegan { player: next_player }); } }
Multiplayer-Specific Mechanics
Turn-Based Effects
Some effects need to track when players take turns:
#![allow(unused)] fn main() { /// Component for effects that trigger at the beginning of a player's turn #[derive(Component, Debug, Clone)] pub struct BeginningOfTurnTrigger { /// Whether this triggers on the controller's turn only pub controller_only: bool, /// Whether this triggers on opponents' turns only pub opponents_only: bool, /// Function to execute when triggered pub effect: fn(Commands, Entity) -> (), } /// System that handles beginning of turn triggers pub fn handle_beginning_of_turn_triggers( mut commands: Commands, turn_order: Res<TurnOrder>, turn_phase: Res<TurnPhase>, triggers: Query<(Entity, &BeginningOfTurnTrigger, &Owner)>, ) { // Only execute during the beginning of turn phase if !matches!(turn_phase.as_ref(), TurnPhase::Beginning(BeginningPhase::Upkeep)) { return; } let current_player = turn_order.current_player(); for (entity, trigger, owner) in triggers.iter() { let is_owner_turn = owner.0 == current_player; // Check if trigger condition is met if (trigger.controller_only && is_owner_turn) || (trigger.opponents_only && !is_owner_turn) || (!trigger.controller_only && !trigger.opponents_only) { (trigger.effect)(commands, entity); } } } }
Simultaneous Effects
In multiplayer games, effects that affect all players need special handling:
#![allow(unused)] fn main() { /// Handles effects that apply to all players simultaneously pub fn handle_global_effects( mut commands: Commands, players: Query<Entity, With<Player>>, global_effects: Query<(Entity, &GlobalEffect)>, ) { for (effect_entity, global_effect) in global_effects.iter() { match global_effect.target_type { GlobalTargetType::AllPlayers => { for player in players.iter() { (global_effect.apply_effect)(commands, effect_entity, player); } }, GlobalTargetType::AllOpponents => { let turn_order = commands.world().resource::<TurnOrder>(); let current_player = turn_order.current_player(); for player in players.iter() { if player != current_player { (global_effect.apply_effect)(commands, effect_entity, player); } } }, // Other global target types... } } } }
Special Turn Interactions
Extra Turns
Commander allows for extra turn effects:
#![allow(unused)] fn main() { #[derive(Resource, Debug)] pub struct ExtraTurns { /// Queue of players taking extra turns pub queue: VecDeque<Entity>, } /// System that handles extra turn insertion pub fn handle_extra_turns( mut turn_order: ResMut<TurnOrder>, mut extra_turns: ResMut<ExtraTurns>, mut turn_events: EventWriter<TurnEvent>, ) { if let Some(player) = extra_turns.queue.pop_front() { // Current player takes an extra turn let current = turn_order.current_player(); if player == current { turn_events.send(TurnEvent::ExtraTurn { player }); } else { // Another player takes the next turn // Temporarily modify turn order let current_index = turn_order.current_player_index; let player_index = turn_order.players.iter().position(|&p| p == player).unwrap(); turn_order.current_player_index = player_index; turn_events.send(TurnEvent::ExtraTurn { player }); // Store original order to restore after the extra turn commands.insert_resource(PendingTurnRestoration { restore_index: current_index, }); } } } }
Turn Modifications
Some cards can modify turn order or skip turns:
#![allow(unused)] fn main() { /// Event for turn order modifications #[derive(Event, Debug, Clone)] pub enum TurnOrderEvent { /// Reverses turn order direction Reverse, /// Skips the next player's turn SkipNext, /// Takes an extra turn after the current one ExtraTurn { player: Entity }, /// Exchanges turns between players ExchangeTurn { player_a: Entity, player_b: Entity }, } /// System that handles turn order modifications pub fn handle_turn_modifications( mut turn_order: ResMut<TurnOrder>, mut extra_turns: ResMut<ExtraTurns>, mut turn_events: EventReader<TurnOrderEvent>, mut game_events: EventWriter<GameEvent>, ) { for event in turn_events.read() { match event { TurnOrderEvent::Reverse => { turn_order.reverse(); game_events.send(GameEvent::TurnOrderReversed); }, TurnOrderEvent::SkipNext => { turn_order.skip_next(); game_events.send(GameEvent::TurnSkipped); }, TurnOrderEvent::ExtraTurn { player } => { extra_turns.queue.push_back(*player); game_events.send(GameEvent::ExtraTurnAdded { player: *player }); }, TurnOrderEvent::ExchangeTurn { player_a, player_b } => { let pos_a = turn_order.players.iter().position(|&p| p == *player_a).unwrap(); let pos_b = turn_order.players.iter().position(|&p| p == *player_b).unwrap(); turn_order.players.swap(pos_a, pos_b); game_events.send(GameEvent::TurnOrderModified); }, } } } }
UI Representation
Multiplayer turns are visually represented in the UI:
- Turn Order Display: Shows all players in order with the current player highlighted
- Active Player Indicator: Clearly highlights whose turn it is
- Turn Direction Indicator: Shows the current direction of play (clockwise/counter-clockwise)
- Extra Turn Queue: Displays any pending extra turns
- Phase Tracker: Shows the current phase of the active player's turn
Testing
Example Test
#![allow(unused)] fn main() { #[test] fn test_multiplayer_turn_order() { // Create test app with required systems let mut app = App::new(); app.add_systems(Update, advance_turn) .add_event::<TurnEvent>() .init_resource::<GameState>(); // Create four players for a typical 4-player game let player1 = app.world.spawn_empty().id(); let player2 = app.world.spawn_empty().id(); let player3 = app.world.spawn_empty().id(); let player4 = app.world.spawn_empty().id(); let players = vec![player1, player2, player3, player4]; // Initialize turn order app.insert_resource(TurnOrder::new(players.clone())); app.insert_resource(TurnPhase::Ending(EndingPhase::End)); // Set game state to end of turn to trigger advancement let mut game_state = GameState::default(); game_state.current_state = GameStateType::EndOfTurn; app.insert_resource(game_state); // First turn should be player1 let turn_order = app.world.resource::<TurnOrder>(); assert_eq!(turn_order.current_player(), player1); // Advance turn app.update(); // Should now be player2's turn let turn_order = app.world.resource::<TurnOrder>(); assert_eq!(turn_order.current_player(), player2); // Test turn events let events = app.world.resource::<Events<TurnEvent>>(); let mut reader = events.get_reader(); let mut saw_end = false; let mut saw_begin = false; for event in reader.read(&events) { match event { TurnEvent::TurnEnded { player } => { assert_eq!(*player, player1); saw_end = true; }, TurnEvent::TurnBegan { player } => { assert_eq!(*player, player2); saw_begin = true; }, _ => {} } } assert!(saw_end, "Should have seen turn ended event"); assert!(saw_begin, "Should have seen turn began event"); } #[test] fn test_reversed_turn_order() { // Similar test setup let mut app = App::new(); // ... // Insert reversed turn order let mut turn_order = TurnOrder::new(players.clone()); turn_order.reverse(); // Direction becomes -1 app.insert_resource(turn_order); // Advance from player1 app.update(); // Should now be player4's turn (counter-clockwise) let turn_order = app.world.resource::<TurnOrder>(); assert_eq!(turn_order.current_player(), player4); } }
Summary
Multiplayer turns in Commander are implemented with a flexible system that:
- Maintains proper turn order in multiplayer games
- Supports direction changes (clockwise/counter-clockwise)
- Handles extra turns and turn skipping
- Processes effects that affect multiple players
- Provides clear UI indicators for turn progression
- Is thoroughly tested for all turn modification scenarios
Extra Turns and Turn Modifications
Overview
Commander implements various turn modification effects from Magic: The Gathering, including extra turns, skipped turns, and modified turn structures. These effects are particularly impactful in a multiplayer environment.
Extra Turns
Extra turns are implemented through a queue system in the TurnManager
:
#![allow(unused)] fn main() { #[derive(Resource)] pub struct TurnManager { // Other fields... pub extra_turns: VecDeque<(Entity, ExtraTurnSource)>, pub skipped_turns: HashSet<Entity>, } #[derive(Debug, Clone)] pub struct ExtraTurnSource { pub source_card: Entity, pub extra_turn_type: ExtraTurnType, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum ExtraTurnType { Standard, CombatOnly, WithRestrictions(Vec<TurnRestriction>), } }
When a player would gain an extra turn:
- The extra turn is added to the queue
- When the current turn ends, the system checks the queue before advancing to the next player's turn
- If there are extra turns queued, the player specified in the queue takes their extra turn
- After all extra turns are complete, normal turn order resumes
Turn Restrictions
Certain cards can impose restrictions on turns, implemented through the TurnRestriction
enum:
#![allow(unused)] fn main() { #[derive(Debug, Clone, PartialEq, Eq)] pub enum TurnRestriction { NoUntap, NoUpkeep, NoDraw, NoMainPhase, NoCombat, MaxSpells(u32), NoActivatedAbilities, MustAttack, CantAttack, NoArtifactsEnchantmentsCreatures, LoseAtEndStep, } }
These restrictions can be associated with specific turns, including extra turns.
Skipped Turns
The system also tracks turns that should be skipped:
#![allow(unused)] fn main() { pub fn process_turn_transition( mut turn_manager: ResMut<TurnManager>, // Other parameters... ) { // Find the next active player let mut next_player_index = (turn_manager.active_player_index + 1) % turn_manager.player_order.len(); let next_player = turn_manager.player_order[next_player_index]; // Check for skipped turns if turn_manager.skipped_turns.contains(&next_player) { turn_manager.skipped_turns.remove(&next_player); // Skip to the player after the one who would skip next_player_index = (next_player_index + 1) % turn_manager.player_order.len(); } // Check for extra turns first if !turn_manager.extra_turns.is_empty() { let (extra_turn_player, source) = turn_manager.extra_turns.pop_front().unwrap(); // Process the extra turn... } else { // Set the next player as active turn_manager.active_player_index = next_player_index; } // Reset for new turn turn_manager.current_phase = Phase::Beginning(BeginningStep::Untap); turn_manager.turn_number += 1; } }
Additional Phase Modifications
The system supports other phase modifications:
- Additional combat phases
- Phase repetition
- Phase reordering (in specific cases)
- Phase duration modifications
Multiplayer Impact
Extra turns and turn modifications have significant impact in Commander:
- Political considerations when taking extra turns
- More targets for "target player skips their next turn" effects
- Greater variance in game length due to turn manipulation
- House rules regarding excessive turn manipulation
Implementation Details
The turn modification system integrates with the event system to ensure proper triggers occur:
#![allow(unused)] fn main() { pub fn extra_turn_system( mut commands: Commands, mut turn_manager: ResMut<TurnManager>, mut extra_turn_events: EventReader<ExtraTurnEvent>, ) { for event in extra_turn_events.iter() { turn_manager.extra_turns.push_back((event.player, event.source.clone())); } } }
Cards that grant or modify turns dispatch appropriate events that are handled by these systems.
Phase Management
Phase and Step Definitions
The Commander implementation uses a hierarchical structure of phases and steps that closely follows the official Magic rules:
#![allow(unused)] fn main() { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Phase { Beginning(BeginningStep), Precombat(PrecombatStep), Combat(CombatStep), Postcombat(PostcombatStep), Ending(EndingStep), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BeginningStep { Untap, Upkeep, Draw, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PrecombatStep { Main, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CombatStep { Beginning, DeclareAttackers, DeclareBlockers, CombatDamage, End, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PostcombatStep { Main, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EndingStep { End, Cleanup, } }
Phase Transitions
Phase transitions are managed by the turn system and occur when:
- All players have passed priority with an empty stack
- Certain turn-based actions are completed
- Special effects cause phase skipping or addition
Phase-Specific Actions
Each phase and step has specific turn-based actions that occur at its beginning:
Beginning Phase
Untap Step
- The active player untaps all permanents they control
- No players receive priority during this step
- "At the beginning of your untap step" triggered abilities trigger
Upkeep Step
- "At the beginning of your upkeep" triggered abilities trigger
- Players receive priority
Draw Step
- Active player draws a card
- "At the beginning of your draw step" triggered abilities trigger
- Players receive priority
Main Phases
- Active player may play land (once per turn)
- Players may cast spells and activate abilities
- No automatic actions occur
Combat Phase
- Detailed in the Combat System documentation
Ending Phase
End Step
- "At the beginning of your end step" triggered abilities trigger
- Players receive priority
Cleanup Step
- Active player discards down to maximum hand size
- Damage is removed from permanents
- "Until end of turn" effects end
- Normally, no player receives priority during this step
- If any state-based actions are performed or triggered abilities trigger, players do receive priority
Special Phase Handling
The phase system accounts for special cases:
#![allow(unused)] fn main() { #[derive(Debug, Clone, PartialEq, Eq)] pub enum TurnRestriction { NoUntap, NoUpkeep, NoDraw, NoMainPhase, NoCombat, MaxSpells(u32), // Other restrictions } }
These phase restrictions can be applied through card effects and are managed by the TurnManager
.
Multiplayer Turn Handling
Overview
Commander's multiplayer nature introduces special considerations for turn management, particularly regarding turn order, player elimination, and simultaneous effects. This document outlines how the turn system handles these multiplayer-specific scenarios.
Player Order Management
Turn order in Commander follows a clockwise rotation from a randomly determined starting player. The turn order is maintained in the TurnManager
:
#![allow(unused)] fn main() { #[derive(Resource)] pub struct TurnManager { // Other fields... pub player_order: Vec<Entity>, } }
At game initialization, the player order is randomized:
#![allow(unused)] fn main() { fn initialize_turn_order( mut commands: Commands, player_query: Query<Entity, With<Player>>, ) { let mut player_entities: Vec<Entity> = player_query.iter().collect(); // Randomize the order player_entities.shuffle(&mut thread_rng()); commands.insert_resource(TurnManager { player_order: player_entities, // Initialize other fields... }); } }
Player Elimination Handling
When a player is eliminated from the game, the turn system must adjust the turn order and potentially the active player and priority indicators:
#![allow(unused)] fn main() { fn handle_player_elimination( mut commands: Commands, mut elimination_events: EventReader<PlayerEliminationEvent>, mut turn_manager: ResMut<TurnManager>, ) { for event in elimination_events.read() { let eliminated_player = event.player; // Remove player from turn order if let Some(pos) = turn_manager.player_order.iter().position(|&p| p == eliminated_player) { turn_manager.player_order.remove(pos); // Adjust current active and priority indices if needed if pos <= turn_manager.active_player_index { turn_manager.active_player_index = (turn_manager.active_player_index - 1) % turn_manager.player_order.len(); } if pos <= turn_manager.priority_player_index { turn_manager.priority_player_index = (turn_manager.priority_player_index - 1) % turn_manager.player_order.len(); } } } } }
Simultaneous Effects
In Commander, many effects can happen simultaneously across multiple players. The system handles these according to Magic's APNAP (Active Player, Non-Active Player) rule:
#![allow(unused)] fn main() { fn handle_simultaneous_effects( turn_manager: Res<TurnManager>, mut simultaneous_events: EventReader<SimultaneousEffectEvent>, mut commands: Commands, ) { let mut effect_queue = Vec::new(); // Collect all simultaneous effects for event in simultaneous_events.read() { effect_queue.push((event.player, event.effect.clone())); } // Sort by APNAP order effect_queue.sort_by(|(player_a, _), (player_b, _)| { let active_player = turn_manager.player_order[turn_manager.active_player_index]; if *player_a == active_player { return Ordering::Less; } if *player_b == active_player { return Ordering::Greater; } // For non-active players, compare positions in turn order let pos_a = turn_manager.player_order.iter().position(|&p| p == *player_a).unwrap(); let pos_b = turn_manager.player_order.iter().position(|&p| p == *player_b).unwrap(); pos_a.cmp(&pos_b) }); // Process effects in sorted order for (player, effect) in effect_queue { // Process each effect... } } }
Multi-Player Variants
The turn system supports different Commander variants:
- Free-for-All: Standard Commander with turn order passing clockwise
- Two-Headed Giant: Teams share turns and life totals
- Star: Five players with win conditions based on eliminating opposing "colors"
- Archenemy: One player versus all others, with the Archenemy having access to scheme cards
Each variant may modify the TurnManager
initialization and behavior:
#![allow(unused)] fn main() { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GameVariant { FreeForAll, TwoHeadedGiant, Star, Archenemy, // Other variants } // The variant affects turn handling pub fn initialize_turn_manager( variant: GameVariant, players: Vec<Entity>, ) -> TurnManager { match variant { GameVariant::TwoHeadedGiant => { // Team members share turns // ... }, GameVariant::Archenemy => { // Archenemy goes first // ... }, _ => { // Standard initialization // ... } } } }
Multiplayer-Specific Considerations
The turn system accounts for multiplayer-specific mechanics:
- Range of Influence: Limited in some variants (like Star)
- Shared Team Turns: In variants like Two-Headed Giant
- Table Politics: Support for game actions like voting
- Monarch: Special designation that passes between players
- Initiative: Tracking which player has the initiative in Undercity dungeon scenarios
Turn and Phase Tests
This section contains tests related to the turn structure and phase mechanics in the Commander format, with a focus on multiplayer interactions.
Multiplayer Turn Structure Tests
The Multiplayer Turn Structure Tests validate the proper handling of turns in a multiplayer environment, including:
- Turn order in multiplayer games
- Turn modification effects (extra turns, skipped turns)
- Phase progression in various game states
- Proper handling of priority in multiplayer scenarios
- Player elimination effects on turn structure
These tests ensure that the multiplayer aspects of the Commander format's turn structure function correctly within the game engine.
Multiplayer Turn Structure Tests
Overview
This document outlines test cases for Commander's multiplayer turn structure, focusing on turn order, extra turns, and phase management in a multiplayer environment. These tests ensure the game properly handles turn-based interactions in a format that can have 3-6 players.
Test Case: Basic Turn Order
Test: Turn Order in Multiplayer Game
#![allow(unused)] fn main() { #[test] fn test_multiplayer_turn_order() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (update_turn_order, next_turn, check_active_player)); // Create a multiplayer game with 4 players let player1 = app.world.spawn(( Player { id: 1 }, PlayerName("Player 1".to_string()), )).id(); let player2 = app.world.spawn(( Player { id: 2 }, PlayerName("Player 2".to_string()), )).id(); let player3 = app.world.spawn(( Player { id: 3 }, PlayerName("Player 3".to_string()), )).id(); let player4 = app.world.spawn(( Player { id: 4 }, PlayerName("Player 4".to_string()), )).id(); // Set up turn order app.insert_resource(TurnOrder { players: vec![player1, player2, player3, player4], current_index: 0, }); app.insert_resource(TurnManager { current_phase: Phase::Beginning(BeginningPhaseStep::Untap), active_player: player1, }); // Validate initial active player let turn_manager = app.world.resource::<TurnManager>(); assert_eq!(turn_manager.active_player, player1); // Execute a full turn and move to next player app.world.send_event(EndTurnEvent); app.update(); // Verify active player changed to player 2 let turn_manager = app.world.resource::<TurnManager>(); assert_eq!(turn_manager.active_player, player2); // Execute another turn app.world.send_event(EndTurnEvent); app.update(); // Verify active player changed to player 3 let turn_manager = app.world.resource::<TurnManager>(); assert_eq!(turn_manager.active_player, player3); // Simulate a full rotation app.world.send_event(EndTurnEvent); app.update(); app.world.send_event(EndTurnEvent); app.update(); // Should be back to player 1 let turn_manager = app.world.resource::<TurnManager>(); assert_eq!(turn_manager.active_player, player1); } }
Test Case: Player Elimination
Test: Removing a Player from Turn Order
#![allow(unused)] fn main() { #[test] fn test_player_elimination() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (update_turn_order, handle_player_elimination, next_turn)); // Create a multiplayer game with 4 players let player1 = app.world.spawn(Player { id: 1 }).id(); let player2 = app.world.spawn(Player { id: 2 }).id(); let player3 = app.world.spawn(Player { id: 3 }).id(); let player4 = app.world.spawn(Player { id: 4 }).id(); // Set up turn order app.insert_resource(TurnOrder { players: vec![player1, player2, player3, player4], current_index: 0, }); app.insert_resource(TurnManager { current_phase: Phase::Beginning(BeginningPhaseStep::Untap), active_player: player1, }); // Player 2 is eliminated app.world.send_event(PlayerEliminatedEvent { player: player2 }); app.update(); // Verify turn order adjusted let turn_order = app.world.resource::<TurnOrder>(); assert_eq!(turn_order.players, vec![player1, player3, player4]); // Current player should still be player 1 let turn_manager = app.world.resource::<TurnManager>(); assert_eq!(turn_manager.active_player, player1); // End turn app.world.send_event(EndTurnEvent); app.update(); // Verify active player skipped player 2 and is now player 3 let turn_manager = app.world.resource::<TurnManager>(); assert_eq!(turn_manager.active_player, player3); } }
Test: Eliminating the Active Player
#![allow(unused)] fn main() { #[test] fn test_active_player_elimination() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (update_turn_order, handle_player_elimination, next_turn)); // Create a multiplayer game with 4 players let player1 = app.world.spawn(Player { id: 1 }).id(); let player2 = app.world.spawn(Player { id: 2 }).id(); let player3 = app.world.spawn(Player { id: 3 }).id(); let player4 = app.world.spawn(Player { id: 4 }).id(); // Set up turn order app.insert_resource(TurnOrder { players: vec![player1, player2, player3, player4], current_index: 0, }); app.insert_resource(TurnManager { current_phase: Phase::Beginning(BeginningPhaseStep::Untap), active_player: player1, }); // Active player is eliminated app.world.send_event(PlayerEliminatedEvent { player: player1 }); app.update(); // Verify turn order adjusted and active player changed let turn_order = app.world.resource::<TurnOrder>(); assert_eq!(turn_order.players, vec![player2, player3, player4]); let turn_manager = app.world.resource::<TurnManager>(); assert_eq!(turn_manager.active_player, player2); } }
Test Case: Extra Turns
Test: Player Takes an Extra Turn
#![allow(unused)] fn main() { #[test] fn test_extra_turn() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (update_turn_order, handle_extra_turns, next_turn)); // Create a multiplayer game with 4 players let player1 = app.world.spawn(Player { id: 1 }).id(); let player2 = app.world.spawn(Player { id: 2 }).id(); let player3 = app.world.spawn(Player { id: 3 }).id(); let player4 = app.world.spawn(Player { id: 4 }).id(); // Set up turn order app.insert_resource(TurnOrder { players: vec![player1, player2, player3, player4], current_index: 0, }); app.insert_resource(TurnManager { current_phase: Phase::Beginning(BeginningPhaseStep::Untap), active_player: player1, }); app.insert_resource(ExtraTurns::default()); // Player 1 gets an extra turn app.world.send_event(ExtraTurnEvent { player: player1, count: 1, source: EntitySource { entity: Entity::PLACEHOLDER }, }); // End current turn app.world.send_event(EndTurnEvent); app.update(); // Verify player 1 gets an extra turn instead of going to player 2 let turn_manager = app.world.resource::<TurnManager>(); assert_eq!(turn_manager.active_player, player1); // End extra turn app.world.send_event(EndTurnEvent); app.update(); // Now it should go to player 2 let turn_manager = app.world.resource::<TurnManager>(); assert_eq!(turn_manager.active_player, player2); } }
Test: Multiple Extra Turns Across Players
#![allow(unused)] fn main() { #[test] fn test_multiple_extra_turns() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (update_turn_order, handle_extra_turns, next_turn)); // Create a multiplayer game with 4 players let player1 = app.world.spawn(Player { id: 1 }).id(); let player2 = app.world.spawn(Player { id: 2 }).id(); let player3 = app.world.spawn(Player { id: 3 }).id(); let player4 = app.world.spawn(Player { id: 4 }).id(); // Set up turn order app.insert_resource(TurnOrder { players: vec![player1, player2, player3, player4], current_index: 0, }); app.insert_resource(TurnManager { current_phase: Phase::Beginning(BeginningPhaseStep::Untap), active_player: player1, }); app.insert_resource(ExtraTurns::default()); // Player 1 grants an extra turn to player 3 app.world.send_event(ExtraTurnEvent { player: player3, count: 1, source: EntitySource { entity: Entity::PLACEHOLDER }, }); // Player 2 grants an extra turn to themselves app.world.send_event(ExtraTurnEvent { player: player2, count: 1, source: EntitySource { entity: Entity::PLACEHOLDER }, }); // End current turn (player 1) app.world.send_event(EndTurnEvent); app.update(); // Regular turn order goes to player 2 let turn_manager = app.world.resource::<TurnManager>(); assert_eq!(turn_manager.active_player, player2); // End turn (player 2) app.world.send_event(EndTurnEvent); app.update(); // Player 2's extra turn happens before moving to player 3 let turn_manager = app.world.resource::<TurnManager>(); assert_eq!(turn_manager.active_player, player2); // End extra turn (player 2) app.world.send_event(EndTurnEvent); app.update(); // Now it's player 3's turn let turn_manager = app.world.resource::<TurnManager>(); assert_eq!(turn_manager.active_player, player3); // End turn (player 3) app.world.send_event(EndTurnEvent); app.update(); // Player 3 gets extra turn let turn_manager = app.world.resource::<TurnManager>(); assert_eq!(turn_manager.active_player, player3); } }
Test Case: Phase Management
Test: Phase Progression Through a Turn
#![allow(unused)] fn main() { #[test] fn test_phase_progression() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (update_phase, handle_phase_transitions)); // Create player let player = app.world.spawn(Player {}).id(); // Set up turn manager app.insert_resource(TurnManager { current_phase: Phase::Beginning(BeginningPhaseStep::Untap), active_player: player, }); // Progress through beginning phase app.update(); assert_eq!(app.world.resource::<TurnManager>().current_phase, Phase::Beginning(BeginningPhaseStep::Untap)); app.world.send_event(NextPhaseEvent); app.update(); assert_eq!(app.world.resource::<TurnManager>().current_phase, Phase::Beginning(BeginningPhaseStep::Upkeep)); app.world.send_event(NextPhaseEvent); app.update(); assert_eq!(app.world.resource::<TurnManager>().current_phase, Phase::Beginning(BeginningPhaseStep::Draw)); // Move to main phase app.world.send_event(NextPhaseEvent); app.update(); assert_eq!(app.world.resource::<TurnManager>().current_phase, Phase::PreCombatMain); // Move to combat app.world.send_event(NextPhaseEvent); app.update(); assert_eq!(app.world.resource::<TurnManager>().current_phase, Phase::Combat(CombatStep::Beginning)); // Cycle through combat steps app.world.send_event(NextPhaseEvent); app.update(); assert_eq!(app.world.resource::<TurnManager>().current_phase, Phase::Combat(CombatStep::DeclareAttackers)); app.world.send_event(NextPhaseEvent); app.update(); assert_eq!(app.world.resource::<TurnManager>().current_phase, Phase::Combat(CombatStep::DeclareBlockers)); app.world.send_event(NextPhaseEvent); app.update(); assert_eq!(app.world.resource::<TurnManager>().current_phase, Phase::Combat(CombatStep::CombatDamage)); app.world.send_event(NextPhaseEvent); app.update(); assert_eq!(app.world.resource::<TurnManager>().current_phase, Phase::Combat(CombatStep::End)); // Move to post-combat main app.world.send_event(NextPhaseEvent); app.update(); assert_eq!(app.world.resource::<TurnManager>().current_phase, Phase::PostCombatMain); // Move to end step app.world.send_event(NextPhaseEvent); app.update(); assert_eq!(app.world.resource::<TurnManager>().current_phase, Phase::Ending(EndingPhaseStep::End)); app.world.send_event(NextPhaseEvent); app.update(); assert_eq!(app.world.resource::<TurnManager>().current_phase, Phase::Ending(EndingPhaseStep::Cleanup)); } }
Test: Turn-Based Actions in Each Phase
#![allow(unused)] fn main() { #[test] fn test_turn_based_actions() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (handle_untap_step, handle_draw_step, handle_phase_transitions)); // Create player with permanents let player = app.world.spawn(( Player {}, Hand::default(), )).id(); // Create some tapped permanents let permanent1 = app.world.spawn(( Card { name: "Permanent 1".to_string() }, Permanent, Status { tapped: true }, Controller { player }, )).id(); let permanent2 = app.world.spawn(( Card { name: "Permanent 2".to_string() }, Permanent, Status { tapped: true }, Controller { player }, )).id(); // Set up turn manager in untap step app.insert_resource(TurnManager { current_phase: Phase::Beginning(BeginningPhaseStep::Untap), active_player: player, }); // Process untap step app.update(); // Verify permanents were untapped let status1 = app.world.get::<Status>(permanent1).unwrap(); let status2 = app.world.get::<Status>(permanent2).unwrap(); assert!(!status1.tapped); assert!(!status2.tapped); // Move to draw step app.world.resource_mut::<TurnManager>().current_phase = Phase::Beginning(BeginningPhaseStep::Draw); // Create library with cards let card1 = app.world.spawn(( Card { name: "Card 1".to_string() }, Zone::Library, Owner { player }, )).id(); let library = app.world.spawn(( Library { owner: player }, Cards { entities: vec![card1] }, )).id(); // Process draw step app.update(); // Verify card was drawn assert_eq!(app.world.get::<Zone>(card1).unwrap(), &Zone::Hand); let hand = app.world.get::<Hand>(player).unwrap(); assert_eq!(hand.cards.len(), 1); assert!(hand.cards.contains(&card1)); } }
Test Case: Priority System
Test: Priority Passing in Turn Order
#![allow(unused)] fn main() { #[test] fn test_priority_passing() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (handle_priority, update_turn_order)); // Create a multiplayer game with 4 players let player1 = app.world.spawn(Player { id: 1 }).id(); let player2 = app.world.spawn(Player { id: 2 }).id(); let player3 = app.world.spawn(Player { id: 3 }).id(); let player4 = app.world.spawn(Player { id: 4 }).id(); // Set up turn order app.insert_resource(TurnOrder { players: vec![player1, player2, player3, player4], current_index: 0, }); app.insert_resource(TurnManager { current_phase: Phase::PreCombatMain, active_player: player1, }); app.insert_resource(PrioritySystem { has_priority: player1, stack_is_empty: true, all_players_passed: false, }); // Active player passes priority app.world.send_event(PriorityPassedEvent { player: player1 }); app.update(); // Check priority passed to next player let priority = app.world.resource::<PrioritySystem>(); assert_eq!(priority.has_priority, player2); // Second player passes app.world.send_event(PriorityPassedEvent { player: player2 }); app.update(); // Check priority passed to next player let priority = app.world.resource::<PrioritySystem>(); assert_eq!(priority.has_priority, player3); // Third player passes app.world.send_event(PriorityPassedEvent { player: player3 }); app.update(); // Check priority passed to last player let priority = app.world.resource::<PrioritySystem>(); assert_eq!(priority.has_priority, player4); // Last player passes app.world.send_event(PriorityPassedEvent { player: player4 }); app.update(); // All players passed, should move to next phase let priority = app.world.resource::<PrioritySystem>(); assert!(priority.all_players_passed); } }
Test: Priority After Casting a Spell
#![allow(unused)] fn main() { #[test] fn test_priority_after_spell() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (handle_priority, handle_spell_cast)); // Create a multiplayer game with 4 players let player1 = app.world.spawn(Player { id: 1 }).id(); let player2 = app.world.spawn(Player { id: 2 }).id(); let player3 = app.world.spawn(Player { id: 3 }).id(); let player4 = app.world.spawn(Player { id: 4 }).id(); // Set up turn order app.insert_resource(TurnOrder { players: vec![player1, player2, player3, player4], current_index: 0, }); app.insert_resource(TurnManager { current_phase: Phase::PreCombatMain, active_player: player1, }); app.insert_resource(PrioritySystem { has_priority: player1, stack_is_empty: true, all_players_passed: false, }); // Create a spell let spell = app.world.spawn(( Card { name: "Instant Spell".to_string() }, Spell, Owner { player: player1 }, )).id(); // Player 1 casts a spell app.world.send_event(SpellCastEvent { caster: player1, spell: spell, }); app.update(); // Verify spell is on the stack let stack = app.world.resource::<Stack>(); assert!(!stack.is_empty()); // Stack is not empty and priority passed to active player again let priority = app.world.resource::<PrioritySystem>(); assert_eq!(priority.has_priority, player1); assert!(!priority.stack_is_empty); // All players need to pass again for spell to resolve app.world.send_event(PriorityPassedEvent { player: player1 }); app.update(); app.world.send_event(PriorityPassedEvent { player: player2 }); app.update(); app.world.send_event(PriorityPassedEvent { player: player3 }); app.update(); app.world.send_event(PriorityPassedEvent { player: player4 }); app.update(); // Spell should resolve and priority goes back to active player let stack = app.world.resource::<Stack>(); assert!(stack.is_empty()); let priority = app.world.resource::<PrioritySystem>(); assert_eq!(priority.has_priority, player1); } }
These test cases ensure the Commander game engine properly handles multiplayer turn structure, including phase management, turn order, and priority passing in multiplayer scenarios.
Commander-Specific Stack and Priority
Overview
This section covers the Commander-specific aspects of the stack and priority system in Rummage. For core stack and priority mechanics that apply to all formats, see the MTG Core Stack and Priority documentation.
Commander Stack Extensions
The Commander format extends the basic MTG stack and priority system with these key features:
- Multiplayer Priority - Modified priority passing for games with 3+ players
- Commander Casting - Special handling for casting commanders from the command zone
- Political Interaction - Support for verbal agreements and diplomacy during priority windows
Contents
- Stack Implementation - Commander-specific stack extensions
- Priority Passing - Priority in multiplayer Commander games
- Special Timing Rules - Format-specific timing considerations
Key Commander Stack Features
Multiplayer Priority Flow
In multiplayer Commander games, priority passes in turn order starting with the active player:
#![allow(unused)] fn main() { pub fn get_next_priority_player( current_player: Entity, player_order: &Vec<Entity>, ) -> Entity { let current_index = player_order.iter().position(|&p| p == current_player).unwrap(); let next_index = (current_index + 1) % player_order.len(); player_order[next_index] } }
This system handles priority passing among all players in the game, ensuring each player has an opportunity to respond before a spell or ability resolves.
Commander Casting from Command Zone
When a player casts their commander from the command zone, special rules apply:
- The commander tax increases the cost by {2} for each previous cast from the command zone
- The commander moves from the command zone to the stack
- All players have an opportunity to respond before it resolves
Political Considerations
While not encoded in the rules, the Commander format involves political dynamics:
- Players may make deals about actions they'll take when they have priority
- Verbal agreements can influence who players target with spells or attacks
- Priority windows are important moments for diplomatic negotiation
Command Zone Integration
The stack system integrates closely with the Command Zone implementation to handle:
- Moving commanders between the command zone and the stack
- Tracking commander tax for casting costs
- Handling commanders with alternative casting methods
Related Systems
Commander stack and priority interact with several other systems:
- Game Zones - Especially the Command Zone
- Player Mechanics - For managing player turns and actions
- Special Rules - For Commander-specific abilities
Testing
Comprehensive tests verify correct handling of stack and priority in multiplayer Commander games, with special attention to:
- Correct priority order in multiplayer scenarios
- Commander tax application when casting from the command zone
- Interaction of simultaneous triggered abilities from multiple players
Next: Stack Implementation
Stack Implementation
Priority Passing
Special Timing Rules
Stack and Priority Tests
This section contains tests related to the stack and priority system in the Commander format.
Stack Resolution Tests
The Stack Resolution Tests validate the proper functioning of the stack in various scenarios, including:
- Proper ordering of spells and abilities on the stack
- Resolution of the stack in LIFO (Last In, First Out) order
- Handling of counterspells and other stack-modifying effects
- Split second and other special timing rules
- Multiplayer priority passing
- Stack interactions with state-based actions
These tests ensure that the stack and priority system conform to Magic: The Gathering rules and behave correctly within the multiplayer context of the Commander format.
Stack Resolution Tests
Overview
This document outlines test cases for the stack system in Commander format. These tests ensure that the stack operates correctly, spells and abilities resolve in the right order, and that priority passing works as expected in this multiplayer format.
Test Case: Basic Stack Resolution
Test: Last In, First Out Resolution Order
#![allow(unused)] fn main() { #[test] fn test_lifo_resolution() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (handle_stack_resolution, update_game_state)); // Create player let player = app.world.spawn(Player {}).id(); // Create spells for the stack let spell1 = app.world.spawn(( Card { name: "First Spell".to_string() }, Spell, StackPosition(0), )).id(); let spell2 = app.world.spawn(( Card { name: "Second Spell".to_string() }, Spell, StackPosition(1), )).id(); let spell3 = app.world.spawn(( Card { name: "Third Spell".to_string() }, Spell, StackPosition(2), )).id(); // Set up stack with spells in order app.insert_resource(Stack { items: vec![spell1, spell2, spell3], }); // Track resolution order app.insert_resource(ResolutionOrder { items: Vec::new() }); // All players pass priority app.insert_resource(PrioritySystem { has_priority: player, stack_is_empty: false, all_players_passed: true, }); // Resolve stack app.update(); // Verify resolution happened in LIFO order let resolution_order = app.world.resource::<ResolutionOrder>(); assert_eq!(resolution_order.items, vec![spell3, spell2, spell1]); } }
Test Case: Interrupting Stack Resolution
Test: Adding to the Stack During Resolution
#![allow(unused)] fn main() { #[test] fn test_stack_interruption() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (handle_stack_resolution, handle_triggered_abilities)); // Create player let player = app.world.spawn(Player {}).id(); // Create an ability that will trigger when a spell resolves let permanent = app.world.spawn(( Card { name: "Ability Source".to_string() }, Permanent, TriggeredAbility { trigger: Trigger::SpellResolution, ability: AbilityType::CreateEffect, }, Controller { player }, )).id(); // Create spells for the stack let spell1 = app.world.spawn(( Card { name: "First Spell".to_string() }, Spell, StackPosition(0), )).id(); let spell2 = app.world.spawn(( Card { name: "Second Spell".to_string() }, Spell, StackPosition(1), )).id(); // Set up stack with spells app.insert_resource(Stack { items: vec![spell1, spell2], }); // All players pass priority app.insert_resource(PrioritySystem { has_priority: player, stack_is_empty: false, all_players_passed: true, }); // Partially resolve stack (just the top spell) app.update(); // Verify the triggered ability was put on the stack let stack = app.world.resource::<Stack>(); assert_eq!(stack.items.len(), 2); // Original spell1 + new triggered ability // Check that the new ability is at the top of the stack let top_item = stack.items.last().unwrap(); assert!(app.world.get::<TriggeredAbility>(*top_item).is_some()); } }
Test Case: Priority Passing in Multiplayer
Test: Full Round of Priority After Spell Cast
#![allow(unused)] fn main() { #[test] fn test_multiplayer_priority() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (handle_priority, handle_spell_cast)); // Create multiple players let player1 = app.world.spawn(Player { id: 1 }).id(); let player2 = app.world.spawn(Player { id: 2 }).id(); let player3 = app.world.spawn(Player { id: 3 }).id(); let player4 = app.world.spawn(Player { id: 4 }).id(); // Set up turn order app.insert_resource(TurnOrder { players: vec![player1, player2, player3, player4], current_index: 0, }); app.insert_resource(TurnManager { current_phase: Phase::PreCombatMain, active_player: player1, }); // Create a spell let spell = app.world.spawn(( Card { name: "Test Spell".to_string() }, Spell, Owner { player: player1 }, )).id(); // Player 1 has priority initially app.insert_resource(PrioritySystem { has_priority: player1, stack_is_empty: true, all_players_passed: false, }); // Player 1 casts a spell app.world.send_event(SpellCastEvent { caster: player1, spell: spell, }); app.update(); // Verify spell is on stack let stack = app.world.resource::<Stack>(); assert_eq!(stack.items.len(), 1); assert_eq!(stack.items[0], spell); // Verify priority returned to active player let priority = app.world.resource::<PrioritySystem>(); assert_eq!(priority.has_priority, player1); // Player 1 passes priority app.world.send_event(PriorityPassedEvent { player: player1 }); app.update(); // Priority passes to player 2 let priority = app.world.resource::<PrioritySystem>(); assert_eq!(priority.has_priority, player2); // Player 2 has opportunity to respond let response_spell = app.world.spawn(( Card { name: "Response Spell".to_string() }, Spell, Owner { player: player2 }, )).id(); // Player 2 casts a response app.world.send_event(SpellCastEvent { caster: player2, spell: response_spell, }); app.update(); // Verify both spells are on stack with response on top let stack = app.world.resource::<Stack>(); assert_eq!(stack.items.len(), 2); assert_eq!(stack.items[1], response_spell); // Verify priority returned to player 2 let priority = app.world.resource::<PrioritySystem>(); assert_eq!(priority.has_priority, player2); // All players need to pass again for anything to resolve app.world.send_event(PriorityPassedEvent { player: player2 }); app.update(); app.world.send_event(PriorityPassedEvent { player: player3 }); app.update(); app.world.send_event(PriorityPassedEvent { player: player4 }); app.update(); app.world.send_event(PriorityPassedEvent { player: player1 }); app.update(); // Top spell should resolve app.update(); // Verify response spell resolved let stack = app.world.resource::<Stack>(); assert_eq!(stack.items.len(), 1); assert_eq!(stack.items[0], spell); } }
Test Case: Split Second and Interrupts
Test: Split Second Prevents Further Responses
#![allow(unused)] fn main() { #[test] fn test_split_second() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (handle_priority, handle_spell_cast, validate_spell_cast)); // Create players let player1 = app.world.spawn(Player { id: 1 }).id(); let player2 = app.world.spawn(Player { id: 2 }).id(); // Set up turn order app.insert_resource(TurnOrder { players: vec![player1, player2], current_index: 0, }); app.insert_resource(TurnManager { current_phase: Phase::PreCombatMain, active_player: player1, }); // Create a split second spell let split_second_spell = app.world.spawn(( Card { name: "Split Second Spell".to_string() }, Spell, SplitSecond, Owner { player: player1 }, )).id(); // Player 1 has priority initially app.insert_resource(PrioritySystem { has_priority: player1, stack_is_empty: true, all_players_passed: false, }); // Player 1 casts split second spell app.world.send_event(SpellCastEvent { caster: player1, spell: split_second_spell, }); app.update(); // Verify split second spell is on stack let stack = app.world.resource::<Stack>(); assert_eq!(stack.items.len(), 1); assert_eq!(stack.items[0], split_second_spell); // Create a response spell let response_spell = app.world.spawn(( Card { name: "Response Spell".to_string() }, Spell, Owner { player: player2 }, )).id(); // Priority passes to player 2 app.world.resource_mut::<PrioritySystem>().has_priority = player2; // Player 2 attempts to cast a response app.world.send_event(SpellCastEvent { caster: player2, spell: response_spell, }); app.update(); // Verify response spell was prevented from being cast let stack = app.world.resource::<Stack>(); assert_eq!(stack.items.len(), 1); // Still only the split second spell // Only mana abilities and special actions can be taken let mana_ability = app.world.spawn(( Card { name: "Mana Source".to_string() }, ManaAbility, Owner { player: player2 }, )).id(); // Player 2 activates a mana ability app.world.send_event(ActivateManaAbilityEvent { player: player2, ability: mana_ability, }); app.update(); // Verify mana ability was allowed assert!(app.world.resource::<Events<ManaProducedEvent>>().is_empty() == false); } }
Test Case: Counterspells and Responses
Test: Counterspell on the Stack
#![allow(unused)] fn main() { #[test] fn test_counterspell() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (handle_stack_resolution, handle_spell_cast, handle_counterspell)); // Create player let player1 = app.world.spawn(Player { id: 1 }).id(); let player2 = app.world.spawn(Player { id: 2 }).id(); // Create a target spell let target_spell = app.world.spawn(( Card { name: "Target Spell".to_string() }, Spell, Owner { player: player1 }, )).id(); // Create a counterspell let counterspell = app.world.spawn(( Card { name: "Counterspell".to_string() }, Spell, CounterspellEffect, Owner { player: player2 }, )).id(); // Put target spell on stack app.insert_resource(Stack { items: vec![target_spell], }); // Player 2 casts counterspell targeting the spell app.world.send_event(SpellCastEvent { caster: player2, spell: counterspell, }); // Set up targets for counterspell app.world.spawn(( Target { source: counterspell, targets: vec![TargetInfo { entity: target_spell, target_type: TargetType::Spell, }], }, )); app.update(); // Verify both spells on stack with counterspell on top let stack = app.world.resource::<Stack>(); assert_eq!(stack.items.len(), 2); assert_eq!(stack.items[1], counterspell); // All players pass priority app.insert_resource(PrioritySystem { has_priority: player1, stack_is_empty: false, all_players_passed: true, }); // Resolve counterspell app.update(); // Verify target spell was countered and removed from stack let stack = app.world.resource::<Stack>(); assert!(stack.items.is_empty()); // Verify target spell moved to graveyard assert_eq!(app.world.get::<Zone>(target_spell).unwrap(), &Zone::Graveyard); } }
Test: Uncounterable Spell
#![allow(unused)] fn main() { #[test] fn test_uncounterable_spell() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (handle_stack_resolution, handle_spell_cast, handle_counterspell)); // Create player let player1 = app.world.spawn(Player { id: 1 }).id(); let player2 = app.world.spawn(Player { id: 2 }).id(); // Create an uncounterable spell let uncounterable_spell = app.world.spawn(( Card { name: "Uncounterable Spell".to_string() }, Spell, CannotBeCountered, Owner { player: player1 }, )).id(); // Create a counterspell let counterspell = app.world.spawn(( Card { name: "Counterspell".to_string() }, Spell, CounterspellEffect, Owner { player: player2 }, )).id(); // Put uncounterable spell on stack app.insert_resource(Stack { items: vec![uncounterable_spell], }); // Player 2 casts counterspell targeting the spell app.world.send_event(SpellCastEvent { caster: player2, spell: counterspell, }); // Set up targets for counterspell app.world.spawn(( Target { source: counterspell, targets: vec![TargetInfo { entity: uncounterable_spell, target_type: TargetType::Spell, }], }, )); app.update(); // Verify both spells on stack let stack = app.world.resource::<Stack>(); assert_eq!(stack.items.len(), 2); // All players pass priority app.insert_resource(PrioritySystem { has_priority: player1, stack_is_empty: false, all_players_passed: true, }); // Resolve counterspell app.update(); // Verify uncounterable spell remains on stack let stack = app.world.resource::<Stack>(); assert_eq!(stack.items.len(), 1); assert_eq!(stack.items[0], uncounterable_spell); // Verify counterspell went to graveyard assert_eq!(app.world.get::<Zone>(counterspell).unwrap(), &Zone::Graveyard); } }
Test Case: Triggered Abilities and The Stack
Test: Multiple Triggered Abilities Order
#![allow(unused)] fn main() { #[test] fn test_multiple_triggers() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (handle_triggered_abilities, order_triggered_abilities)); // Create players let player1 = app.world.spawn(Player { id: 1 }).id(); let player2 = app.world.spawn(Player { id: 2 }).id(); // Turn order app.insert_resource(TurnOrder { players: vec![player1, player2], current_index: 0, }); app.insert_resource(TurnManager { current_phase: Phase::Beginning(BeginningPhaseStep::Upkeep), active_player: player1, }); // Create permanents with upkeep triggers for both players let permanent1 = app.world.spawn(( Card { name: "Permanent 1".to_string() }, Permanent, TriggeredAbility { trigger: Trigger::Upkeep(TriggerController::ActivePlayer), ability: AbilityType::DrawCard, }, Controller { player: player1 }, )).id(); let permanent2 = app.world.spawn(( Card { name: "Permanent 2".to_string() }, Permanent, TriggeredAbility { trigger: Trigger::Upkeep(TriggerController::ActivePlayer), ability: AbilityType::GainLife(1), }, Controller { player: player1 }, )).id(); let permanent3 = app.world.spawn(( Card { name: "Permanent 3".to_string() }, Permanent, TriggeredAbility { trigger: Trigger::Upkeep(TriggerController::ActivePlayer), ability: AbilityType::LoseLife(1), }, Controller { player: player2 }, )).id(); // Trigger abilities app.world.send_event(PhaseChangedEvent { new_phase: Phase::Beginning(BeginningPhaseStep::Upkeep), }); app.update(); // Verify triggers were put on stack let stack = app.world.resource::<Stack>(); assert_eq!(stack.items.len(), 3); // Verify triggers from active player go on stack in APNAP order // (Active Player, Non-Active Player) let stack_items = &stack.items; // Active player's abilities should be first (in controller's choice order) let first_two_controllers: Vec<Entity> = stack_items.iter() .take(2) .map(|e| app.world.get::<Controller>(*e).unwrap().player) .collect(); // The first two should belong to player1 (active player) assert_eq!(first_two_controllers, vec![player1, player1]); // The last ability should belong to player2 let last_controller = app.world.get::<Controller>(stack_items[2]).unwrap().player; assert_eq!(last_controller, player2); } }
Test Case: Stack and State-Based Actions
Test: State-Based Actions Between Stack Resolutions
#![allow(unused)] fn main() { #[test] fn test_state_based_actions_and_stack() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (handle_stack_resolution, handle_state_based_actions)); // Create player and creature let player = app.world.spawn(Player {}).id(); let creature = app.world.spawn(( Card { name: "Creature".to_string() }, Creature { power: 3, toughness: 3 }, Health { current: 3, maximum: 3 }, Zone::Battlefield, Owner { player }, )).id(); // Create damage spell let damage_spell = app.world.spawn(( Card { name: "Lightning Bolt".to_string() }, Spell, DamageEffect { amount: 3 }, Owner { player }, )).id(); // Create spell target app.world.spawn(( Target { source: damage_spell, targets: vec![TargetInfo { entity: creature, target_type: TargetType::Creature, }], }, )); // Create heal spell that will be cast in response let heal_spell = app.world.spawn(( Card { name: "Healing Touch".to_string() }, Spell, HealEffect { amount: 3 }, Owner { player }, )).id(); // Create heal target app.world.spawn(( Target { source: heal_spell, targets: vec![TargetInfo { entity: creature, target_type: TargetType::Creature, }], }, )); // Put spells on stack in order (heal on top, will resolve first) app.insert_resource(Stack { items: vec![damage_spell, heal_spell], }); // Setup priority for resolution app.insert_resource(PrioritySystem { has_priority: player, stack_is_empty: false, all_players_passed: true, }); // Resolve heal spell app.update(); // Verify creature healed to full let health = app.world.get::<Health>(creature).unwrap(); assert_eq!(health.current, 3); // Still one spell on stack let stack = app.world.resource::<Stack>(); assert_eq!(stack.items.len(), 1); // Resolve damage spell app.update(); // Verify creature took damage let health = app.world.get::<Health>(creature).unwrap(); assert_eq!(health.current, 0); // Verify stack is empty let stack = app.world.resource::<Stack>(); assert!(stack.items.is_empty()); // Apply state-based actions check app.update(); // Verify creature died due to state-based actions assert_eq!(app.world.get::<Zone>(creature).unwrap(), &Zone::Graveyard); } }
These test cases ensure the stack resolution system functions correctly, handling priority passing, triggered abilities, counterspells, and state-based actions in accordance with Commander's rules.
Commander-Specific Combat Mechanics
Overview
This section covers the Commander-specific combat mechanics in the Rummage game engine. For core combat mechanics that apply to all formats, see the MTG Core Combat documentation.
Commander Combat Extensions
Commander extends the basic MTG combat system with these key mechanics:
- Commander Damage - A player who has been dealt 21 or more combat damage by the same commander loses the game
- Multiplayer Combat - Special considerations for attacking and blocking in a multiplayer environment
- Commander-specific Combat Abilities - Handling abilities that interact with commander status
Contents
- Commander Damage - Implementation of the 21-damage loss condition
- Multiplayer Combat - Special rules for combat with multiple players
- Combat Verification - Validation of combat decisions in multiplayer scenarios
- Combat Abilities - Implementation of commander-specific combat abilities
Key Commander Combat Features
Commander Damage Tracking
The system tracks commander damage separately from regular damage:
#![allow(unused)] fn main() { #[derive(Component)] pub struct CommanderDamageTracker { // Maps commander entity -> damage dealt to player pub damage_taken: HashMap<Entity, u32>, } }
When a player takes 21 or more combat damage from the same commander, they lose the game regardless of their life total.
Multiplayer Combat Dynamics
Commander's multiplayer format introduces unique combat dynamics:
- Players can attack any opponent, not just the player to their left/right
- Political considerations affect attack and block decisions
- Players can make deals regarding combat (though these aren't enforced by the game rules)
Combat in Multiplayer Politics
Combat is central to the political dynamics of Commander:
- Attacks signal aggression and can lead to retaliation
- Defending other players can forge temporary alliances
- Commander damage creates an additional threat vector beyond life totals
Related Systems
Commander combat interacts with several other systems:
- Player Mechanics - For life total and commander damage tracking
- Game Mechanics - For state-based actions that check commander damage thresholds
- Special Rules - For politics and multiplayer considerations
Testing
The tests directory contains comprehensive test cases for validating commander-specific combat mechanics, with special focus on commander damage tracking and multiplayer scenarios.
Next: Commander Damage
Combat Phases
Overview
The Combat Phase in Commander follows the standard Magic: The Gathering combat sequence, with special considerations for the multiplayer nature of the format. This document provides an overview of the combat phase structure and links to detailed implementation documentation for each step.
Combat Phase Sequence
The Combat Phase consists of five distinct steps:
- Beginning of Combat Step - The phase begins and "at beginning of combat" triggered abilities go on the stack
- Declare Attackers Step - The active player declares attackers and "when attacks" triggered abilities go on the stack
- Declare Blockers Step - Each defending player declares blockers and "when blocks/blocked" triggered abilities go on the stack
- Combat Damage Step - Combat damage is assigned and dealt, and "when deals damage" triggered abilities go on the stack
- End of Combat Step - The phase ends and "at end of combat" triggered abilities go on the stack
Implementation Architecture
Combat phases are implemented using a combination of phase-specific systems and a shared combat state:
#![allow(unused)] fn main() { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum CombatStep { Beginning, DeclareAttackers, DeclareBlockers, FirstStrike, CombatDamage, End, } // Systems for handling different combat steps pub fn beginning_of_combat_system( mut combat_system: ResMut<CombatSystem>, turn_manager: Res<TurnManager>, mut commands: Commands, // Other system parameters ) { // Implementation } pub fn declare_attackers_system( mut combat_system: ResMut<CombatSystem>, turn_manager: Res<TurnManager>, // Other system parameters ) { // Implementation } // And so on for other combat steps }
Detailed Phase Documentation
Each combat step has its own specialized implementation with unique rules and edge cases:
- Beginning of Combat - Initialization of combat and "beginning of combat" triggers
- Declare Attackers - Attack declaration, restrictions, and requirements
- Declare Blockers - Block declaration, restrictions, and requirements
- Combat Damage - Damage assignment, ordering, and resolution
- End of Combat - Combat cleanup and "end of combat" triggers
Combat Phase Transitions
The transition between combat steps is managed by the TurnManager
, which ensures that:
- Each step is processed in the correct order
- Priority is passed to all players in turn order during each step
- The stack is emptied before proceeding to the next step
- Any special effects that modify the combat phase flow are properly handled
Integration with Turn Structure
The combat phase is integrated into the overall turn structure via the TurnManager
:
#![allow(unused)] fn main() { impl TurnManager { pub fn advance_phase(&mut self) -> Result<Phase, TurnError> { self.current_phase = match self.current_phase { // Other phases... Phase::Combat(CombatStep::Beginning) => Phase::Combat(CombatStep::DeclareAttackers), Phase::Combat(CombatStep::DeclareAttackers) => Phase::Combat(CombatStep::DeclareBlockers), Phase::Combat(CombatStep::DeclareBlockers) => { if self.has_first_strike_creatures() { Phase::Combat(CombatStep::FirstStrike) } else { Phase::Combat(CombatStep::CombatDamage) } }, Phase::Combat(CombatStep::FirstStrike) => Phase::Combat(CombatStep::CombatDamage), Phase::Combat(CombatStep::CombatDamage) => Phase::Combat(CombatStep::End), Phase::Combat(CombatStep::End) => Phase::Postcombat(PostcombatStep::Main), // Other phases... }; // Further implementation } } }
Commander Damage
Overview
Commander Damage is a fundamental aspect of the Commander format. Any player who has taken 21 or more combat damage from a single commander loses the game. This unique rule adds an additional win condition and strategic layer to the multiplayer format. The implementation must accurately track, accumulate, and verify commander damage while handling edge cases like commander ownership changes or damage redirection effects.
Random Effects and Commander Damage
Commander damage can be affected by various random effects, such as:
- Coin flips that double damage or prevent damage
- Dice rolls that modify combat damage
- Random damage redirection
For test cases involving commander damage and random mechanics, see the Random Mechanics documentation.
Core Implementation
#![allow(unused)] fn main() { // Player component with commander damage tracking #[derive(Component)] pub struct Player { pub life_total: i32, pub commander_damage: HashMap<Entity, u32>, // Other player state fields omitted } // System to process commander damage pub fn commander_damage_system( mut player_query: Query<(Entity, &mut Player)>, combat_system: Res<CombatSystem>, commanders: Query<Entity, With<Commander>>, mut game_events: EventWriter<GameEvent>, ) { // Collect any combat damage dealt by commanders let commander_damage_events = combat_system.combat_history .iter() .filter_map(|event| { if let CombatEvent::DamageDealt { source, target, amount, is_commander_damage } = event { if *is_commander_damage && commanders.contains(*source) { return Some((*source, *target, *amount)); } } None }) .collect::<Vec<_>>(); // Process the commander damage for (commander, target, amount) in commander_damage_events { for (player_entity, mut player) in player_query.iter_mut() { if player_entity == target { // Apply commander damage let previous_damage = player.commander_damage.get(&commander).copied().unwrap_or(0); let total_damage = previous_damage + amount; player.commander_damage.insert(commander, total_damage); // Check for commander damage loss condition if total_damage >= 21 { game_events.send(GameEvent::PlayerLost { player: player_entity, reason: LossReason::CommanderDamage(commander), }); } } } } } }
Damage Application System
The combat damage system needs special handling for commander damage:
#![allow(unused)] fn main() { pub fn apply_combat_damage_system( mut combat_system: ResMut<CombatSystem>, turn_manager: Res<TurnManager>, mut creature_query: Query<(Entity, &mut Creature, Option<&Commander>)>, mut player_query: Query<(Entity, &mut Player)>, mut planeswalker_query: Query<(Entity, &mut Planeswalker)>, mut game_events: EventWriter<GameEvent>, ) { // Only run during combat damage step if turn_manager.current_phase != Phase::Combat(CombatStep::CombatDamage) { return; } // Process each attacker that wasn't blocked for (attacker, attack_data) in combat_system.attackers.iter() { // Skip attackers that were blocked if combat_system.blockers.values().any(|block_data| block_data.blocked_attackers.contains(attacker)) { continue; } // Get attacker data if let Ok((_, creature, commander)) = creature_query.get(*attacker) { let power = creature.power; let is_commander = commander.is_some(); // Apply damage to defender let defender = attack_data.defender; // Check if defender is a player if let Ok((player_entity, mut player)) = player_query.get_mut(defender) { // Apply damage to player player.life_total -= power as i32; // Record damage event combat_system.combat_history.push_back(CombatEvent::DamageDealt { source: *attacker, target: player_entity, amount: power, is_commander_damage: is_commander, }); // Check if player lost due to life total if player.life_total <= 0 { game_events.send(GameEvent::PlayerLost { player: player_entity, reason: LossReason::ZeroLife, }); } } // Check if defender is a planeswalker else if let Ok((planeswalker_entity, mut planeswalker)) = planeswalker_query.get_mut(defender) { // Apply damage to planeswalker planeswalker.loyalty -= power as i32; // Record damage event combat_system.combat_history.push_back(CombatEvent::DamageDealt { source: *attacker, target: planeswalker_entity, amount: power, is_commander_damage: false, // Commander damage only applies to players }); // Check if planeswalker was destroyed if planeswalker.loyalty <= 0 { game_events.send(GameEvent::PlaneswalkerDestroyed { planeswalker: planeswalker_entity, }); } } } } // Similar processing for blocked attackers // Implementation details omitted for brevity } }
Handling Commander Identity Changes
When a commander changes identity (e.g., due to effects like Sakashima of a Thousand Faces), the damage tracking needs to be maintained:
#![allow(unused)] fn main() { pub fn handle_commander_identity_change( mut commands: Commands, mut combat_system: ResMut<CombatSystem>, mut player_query: Query<&mut Player>, commander_query: Query<(Entity, &Commander, &OriginalEntity)>, ) { for (current_entity, commander, original) in commander_query.iter() { // If the commander has a different original entity if current_entity != original.0 { // Update all players' commander damage tracking for mut player in player_query.iter_mut() { if let Some(damage) = player.commander_damage.remove(&original.0) { player.commander_damage.insert(current_entity, damage); } } // Update combat system tracking if needed for attack_data in combat_system.attackers.values_mut() { if attack_data.attacker == original.0 { attack_data.attacker = current_entity; attack_data.is_commander = true; } } } } } }
UI Representation
The commander damage tracking needs to be visually represented to players:
#![allow(unused)] fn main() { pub fn update_commander_damage_ui_system( player_query: Query<(Entity, &Player)>, commander_query: Query<(Entity, &Commander, &Card)>, mut ui_state: ResMut<UiState>, ) { // Update UI representation of commander damage for each player for (player_entity, player) in player_query.iter() { let mut commander_damage_display = Vec::new(); for (commander_entity, _, card) in commander_query.iter() { let damage = player.commander_damage.get(&commander_entity).copied().unwrap_or(0); commander_damage_display.push(CommanderDamageDisplay { commander_entity, commander_name: card.name.clone(), damage, progress: (damage as f32) / 21.0, // For progress bar visualization lethal: damage >= 21, }); } // Sort by damage amount (highest first) commander_damage_display.sort_by(|a, b| b.damage.cmp(&a.damage)); // Update UI state ui_state.player_commander_damage.insert(player_entity, commander_damage_display); } } }
Edge Cases
Damage Redirection
Some effects can redirect damage, which needs special handling for commander damage:
#![allow(unused)] fn main() { pub fn handle_damage_redirection( mut combat_system: ResMut<CombatSystem>, redirection_effects: Query<(Entity, &DamageRedirection)>, ) { // Check for redirection effects when processing damage events let mut redirected_damage = Vec::new(); for event in combat_system.pending_combat_events.iter() { if let CombatEvent::DamageDealt { source, target, amount, is_commander_damage } = event { // Check for redirection effects affecting this target for (effect_entity, redirection) in redirection_effects.iter() { if redirection.target == *target { // Redirect the damage redirected_damage.push(CombatEvent::DamageDealt { source: *source, target: redirection.redirect_to, amount: *amount, // Important: Commander damage is preserved ONLY if damage source doesn't change is_commander_damage: *is_commander_damage && redirection.preserve_source, }); } } } } // Add redirected damage events to combat history for event in redirected_damage { combat_system.combat_history.push_back(event); } } }
Change of Control
When a commander changes control, the damage tracking needs to be maintained:
#![allow(unused)] fn main() { pub fn handle_commander_control_change( mut player_query: Query<&mut Player>, mut control_change_events: EventReader<ControlChangeEvent>, commander_query: Query<Entity, With<Commander>>, ) { // Process control change events for event in control_change_events.iter() { // Only care about commanders changing control if commander_query.contains(event.entity) { // Commander damage is tracked by entity, so no changes needed to players' tracking // The entity's controller changes but damage tracking by entity ID remains the same // Log the event for record-keeping info!("Commander {:?} changed control from {:?} to {:?}", event.entity, event.old_controller, event.new_controller); } } } }
Double Strike and Commander Damage
Double strike creatures need special handling for commander damage:
#![allow(unused)] fn main() { pub fn handle_double_strike_commander_damage( mut combat_system: ResMut<CombatSystem>, creature_query: Query<(Entity, &Creature, Option<&Commander>)>, ) { // Get all double strike commanders let double_strike_commanders = creature_query.iter() .filter_map(|(entity, creature, commander)| { if commander.is_some() && creature.has_ability(Ability::Keyword(Keyword::DoubleStrike)) { Some(entity) } else { None } }) .collect::<HashSet<_>>(); // Special handling for double strike damage events let mut double_strike_events = Vec::new(); for event in combat_system.combat_history.iter() { if let CombatEvent::DamageDealt { source, target, amount, is_commander_damage } = event { if *is_commander_damage && double_strike_commanders.contains(source) { // During first strike, we need to ensure this damage is tracked separately // from the regular damage that will come after if combat_system.active_combat_step == Some(CombatStep::FirstStrike) { double_strike_events.push((*source, *target, *amount)); } } } } // Record these events explicitly for commander damage tracking for (source, target, amount) in double_strike_events { info!("Tracking double strike first strike damage from commander {:?} to {:?}: {}", source, target, amount); } } }
Testing Strategy
Unit Tests
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; // Test basic commander damage tracking #[test] fn test_basic_commander_damage() { // Setup test environment let mut app = App::new(); app.add_systems(Update, commander_damage_system); // Create test entities and components let commander = app.world.spawn(( Card::default(), Commander, Creature { power: 5, toughness: 5, ..Default::default() }, )).id(); let player = app.world.spawn(( Player { life_total: 40, commander_damage: HashMap::new(), ..Default::default() }, )).id(); // Create a combat system resource with damage history let mut combat_system = CombatSystem::default(); combat_system.combat_history.push_back(CombatEvent::DamageDealt { source: commander, target: player, amount: 5, is_commander_damage: true, }); app.insert_resource(combat_system); // Run the system app.update(); // Verify commander damage was tracked let player_entity = app.world.entity(player); let player_component = player_entity.get::<Player>().unwrap(); assert_eq!(player_component.commander_damage.get(&commander), Some(&5)); } // Test commander damage loss condition #[test] fn test_commander_damage_loss() { // Setup test environment with 21 commander damage // Test code omitted for brevity // Run the system app.update(); // Verify loss event was sent let events = app.world.resource::<Events<GameEvent>>(); let mut event_reader = EventReader::<GameEvent>::from_resource(events); let mut found_loss_event = false; for event in event_reader.iter() { if let GameEvent::PlayerLost { player: p, reason: LossReason::CommanderDamage(c) } = event { if *p == player && *c == commander { found_loss_event = true; break; } } } assert!(found_loss_event, "Player loss event not found"); } // Test commander damage from multiple commanders #[test] fn test_multiple_commander_damage() { // Setup test with multiple commanders dealing damage // Test code omitted for brevity // Verify each commander's damage is tracked separately let player_entity = app.world.entity(player); let player_component = player_entity.get::<Player>().unwrap(); assert_eq!(player_component.commander_damage.get(&commander1), Some(&15)); assert_eq!(player_component.commander_damage.get(&commander2), Some(&10)); // Verify player hasn't lost yet (no single commander has dealt 21+ damage) let events = app.world.resource::<Events<GameEvent>>(); let mut event_reader = EventReader::<GameEvent>::from_resource(events); for event in event_reader.iter() { if let GameEvent::PlayerLost { player: _, reason: _ } = event { panic!("Player should not have lost yet"); } } } } }
Integration Tests
#![allow(unused)] fn main() { #[cfg(test)] mod integration_tests { use super::*; // Test commander damage through complete combat #[test] fn test_commander_damage_through_combat() { // Setup full game environment let mut app = setup_test_game(); // Setup a commander attacking a player let commander = /* create commander entity */; let player = /* create player entity */; // Setup the attack let mut combat_system = app.world.resource_mut::<CombatSystem>(); combat_system.attackers.insert(commander, AttackData { attacker: commander, defender: player, is_commander: true, requirements: Vec::new(), restrictions: Vec::new(), }); // Advance through combat damage step advance_to_combat_damage(&mut app); // Run the apply damage system app.update(); // Verify commander damage was tracked let player_entity = app.world.entity(player); let player_component = player_entity.get::<Player>().unwrap(); // Assuming a 5 power commander assert_eq!(player_component.commander_damage.get(&commander), Some(&5)); } // Test commander damage accumulation over multiple turns #[test] fn test_commander_damage_accumulation() { // Setup game and play multiple turns with commander damage // Test code omitted for brevity // Verify accumulated commander damage let player_entity = app.world.entity(player); let player_component = player_entity.get::<Player>().unwrap(); assert_eq!(player_component.commander_damage.get(&commander), Some(&15)); // Deal more damage to hit 21 // Test code omitted for brevity // Verify player lost let events = app.world.resource::<Events<GameEvent>>(); let mut event_reader = EventReader::<GameEvent>::from_resource(events); let mut found_loss_event = false; for event in event_reader.iter() { if let GameEvent::PlayerLost { player: p, reason: LossReason::CommanderDamage(c) } = event { if *p == player && *c == commander { found_loss_event = true; break; } } } assert!(found_loss_event, "Player loss event not found"); } // Test commander damage with protection/prevention #[test] fn test_commander_damage_prevention() { // Setup game with protection effects // Test code omitted for brevity // Verify commander damage was prevented let player_entity = app.world.entity(player); let player_component = player_entity.get::<Player>().unwrap(); assert_eq!(player_component.commander_damage.get(&commander), Some(&0)); } } }
Performance Considerations
-
HashMap Efficiency: Commander damage is tracked using a HashMap which provides O(1) lookups, but care should be taken to avoid unnecessary cloning or rebuilding.
-
Event Processing: Combat damage events are processed in batches to minimize system overhead.
-
UI Updates: Commander damage UI updates should only happen when damage values change or during specific UI refresh cycles to avoid performance impact.
Implementation Recommendations
-
Persistent Storage: In a networked game, commander damage should be persisted on the server and synchronized to clients.
-
Undo Support: The system should support undoing or adjusting commander damage in case of rule disputes or corrections.
-
History Tracking: Maintain a searchable history of commander damage events for game replay and verification.
-
Visual Feedback: Provide clear visual feedback when commander damage is getting close to lethal levels (e.g., at 15+ damage).
Commander damage tracking is a critical component of the Commander format and requires careful implementation to ensure game rules are correctly enforced while maintaining good performance even in complex game states.
Multiplayer Combat
Overview
Commander is inherently a multiplayer format, which introduces unique complexities to the combat system. Unlike one-on-one matches, multiplayer combat allows a player to attack multiple opponents simultaneously and introduces political elements that affect combat decisions. This document details how the multiplayer aspects of combat are implemented, tested, and verified in our game engine.
Core Multiplayer Combat Features
Multiple Attack Targets
In Commander, the active player can declare attackers against different opponents in the same combat phase. This is implemented through the attack declaration system:
#![allow(unused)] fn main() { pub fn validate_multiplayer_attacks( combat_system: Res<CombatSystem>, turn_manager: Res<TurnManager>, player_query: Query<(Entity, &Player)>, mut game_events: EventWriter<GameEvent>, ) { let active_player = turn_manager.get_active_player(); // Group attacks by defender let mut attacks_by_defender: HashMap<Entity, Vec<Entity>> = HashMap::new(); for (attacker, attack_data) in &combat_system.attackers { attacks_by_defender.entry(attack_data.defender) .or_insert_with(Vec::new) .push(*attacker); } // Verify all defenders are opponents for (defender, attackers) in &attacks_by_defender { // Skip planeswalkers (they're handled separately) if !player_query.contains(*defender) { continue; } // Verify defender is not the active player if *defender == active_player { game_events.send(GameEvent::InvalidAttackTarget { attacker: attackers[0], // Just report one of the attackers defender: *defender, reason: "Cannot attack yourself".to_string(), }); } } // Log all attack declarations for game history game_events.send(GameEvent::MultiplayerAttacksDeclared { active_player, attacks_by_defender: attacks_by_defender.clone(), }); } }
Defending Player Choice
When a player is being attacked, they alone make blocking decisions for those attackers:
#![allow(unused)] fn main() { pub fn handle_multiplayer_blocks( mut combat_system: ResMut<CombatSystem>, turn_manager: Res<TurnManager>, mut block_events: EventReader<BlockDeclarationEvent>, player_query: Query<Entity, With<Player>>, ) { // Group attackers by defending player let mut attackers_by_defender: HashMap<Entity, Vec<Entity>> = HashMap::new(); for (attacker, attack_data) in &combat_system.attackers { if player_query.contains(attack_data.defender) { attackers_by_defender.entry(attack_data.defender) .or_insert_with(Vec::new) .push(*attacker); } } // Process block declarations from each defending player for event in block_events.iter() { let BlockDeclarationEvent { player, blocker, attacker } = event; // Verify the player is declaring blocks against attackers targeting them if let Some(attackers) = attackers_by_defender.get(player) { if attackers.contains(attacker) { // This is a valid block declaration if let Some(block_data) = combat_system.blockers.get_mut(blocker) { block_data.blocked_attackers.push(*attacker); } else { combat_system.blockers.insert(*blocker, BlockData { blocker: *blocker, blocked_attackers: vec![*attacker], requirements: Vec::new(), restrictions: Vec::new(), }); } } else { // Player trying to block an attacker not targeting them warn!("Player {:?} tried to block attacker {:?} not targeting them", player, attacker); } } } } }
Political Game Elements
Goad Mechanic
Goad is a Commander-focused mechanic that forces creatures to attack players other than you. This is implemented as follows:
#![allow(unused)] fn main() { #[derive(Component)] pub struct Goaded { pub source: Entity, pub until_end_of_turn: bool, } pub fn apply_goad_requirements( mut combat_system: ResMut<CombatSystem>, goaded_query: Query<(Entity, &Goaded, &Controllable)>, turn_manager: Res<TurnManager>, ) { let active_player = turn_manager.get_active_player(); // Find all goaded creatures controlled by the active player for (entity, goaded, controllable) in goaded_query.iter() { if controllable.controller == active_player { // Add attack requirement - must attack if able combat_system.add_attack_requirement(entity, AttackRequirement::MustAttack); // Add attack restriction - can't attack the goad source combat_system.add_attack_restriction(entity, AttackRestriction::CantAttackPlayer(goaded.source)); } } } pub fn cleanup_goad_effects( mut commands: Commands, goaded_query: Query<(Entity, &Goaded)>, turn_manager: Res<TurnManager>, ) { // Only run during end step if turn_manager.current_phase != Phase::Ending(EndingStep::End) { return; } // Remove goad effects that last until end of turn for (entity, goaded) in goaded_query.iter() { if goaded.until_end_of_turn { commands.entity(entity).remove::<Goaded>(); } } } }
Monarch Mechanic
The monarch is another multiplayer-focused mechanic that encourages combat:
#![allow(unused)] fn main() { #[derive(Resource)] pub struct Monarch(pub Option<Entity>); pub fn monarch_attack_trigger( mut monarch: ResMut<Monarch>, combat_system: Res<CombatSystem>, turn_manager: Res<TurnManager>, mut game_events: EventWriter<GameEvent>, ) { // Only check at combat damage step if turn_manager.current_phase != Phase::Combat(CombatStep::CombatDamage) { return; } // Find players who were dealt combat damage let mut damaged_players = HashSet::new(); for event in combat_system.combat_history.iter() { if let CombatEvent::DamageDealt { source, target, amount, .. } = event { if *amount > 0 && combat_system.attackers.contains_key(source) { damaged_players.insert(*target); } } } // Check if the monarch was dealt damage if let Some(current_monarch) = monarch.0 { if damaged_players.contains(¤t_monarch) { // Find who dealt damage to the monarch for event in combat_system.combat_history.iter() { if let CombatEvent::DamageDealt { source, target, amount, .. } = event { if *amount > 0 && *target == current_monarch && combat_system.attackers.contains_key(source) { // Get the controller of the attacking creature if let Some(controller) = get_controller(*source) { if controller != current_monarch { // Change the monarch monarch.0 = Some(controller); game_events.send(GameEvent::MonarchChanged { old_monarch: current_monarch, new_monarch: controller, reason: "Combat damage".to_string(), }); // Only change once (first damage) break; } } } } } } } } }
Multiplayer Damage Tracking
In multiplayer games, damage source tracking becomes more important, especially for commander damage:
#![allow(unused)] fn main() { pub fn track_multiplayer_combat_damage( combat_system: Res<CombatSystem>, mut player_query: Query<(Entity, &mut Player)>, commander_query: Query<Entity, With<Commander>>, mut game_events: EventWriter<GameEvent>, ) { // Extract all combat damage events let damage_events = combat_system.combat_history .iter() .filter_map(|event| { if let CombatEvent::DamageDealt { source, target, amount, is_commander_damage } = event { Some((*source, *target, *amount, *is_commander_damage)) } else { None } }) .collect::<Vec<_>>(); // Process commander damage for (source, target, amount, is_commander_damage) in damage_events { // Only process if the source is a commander and target is a player if is_commander_damage && commander_query.contains(source) { for (player_entity, mut player) in player_query.iter_mut() { if player_entity == target { // Update commander damage tracking let previous_damage = player.commander_damage.get(&source).copied().unwrap_or(0); let new_damage = previous_damage + amount; player.commander_damage.insert(source, new_damage); // Check for commander damage loss condition if new_damage >= 21 { game_events.send(GameEvent::PlayerLost { player: player_entity, reason: LossReason::CommanderDamage(source), }); } } } } } } }
Teamwork Mechanics
Some Commander variants include team play, which requires special handling:
#![allow(unused)] fn main() { #[derive(Component)] pub struct Team(pub u32); pub fn validate_team_attacks( combat_system: Res<CombatSystem>, team_query: Query<(Entity, &Team)>, mut game_events: EventWriter<GameEvent>, ) { // Create team mappings let mut player_teams = HashMap::new(); for (entity, team) in team_query.iter() { player_teams.insert(entity, team.0); } // Check for attacks against teammates for (attacker, attack_data) in &combat_system.attackers { if let Some(attacker_controller) = get_controller(*attacker) { if let (Some(attacker_team), Some(defender_team)) = ( player_teams.get(&attacker_controller), player_teams.get(&attack_data.defender) ) { if attacker_team == defender_team { // This is an attack against a teammate game_events.send(GameEvent::TeamAttack { attacker: *attacker, defender: attack_data.defender, team: *attacker_team, }); } } } } } }
Multiplayer Edge Cases
Player Elimination During Combat
If a player is eliminated during combat, any attackers targeting them need to be handled:
#![allow(unused)] fn main() { pub fn handle_player_elimination_during_combat( mut combat_system: ResMut<CombatSystem>, mut player_elimination_events: EventReader<PlayerEliminatedEvent>, mut commands: Commands, ) { for event in player_elimination_events.iter() { let eliminated_player = event.player; // Remove any attackers targeting the eliminated player let attackers_to_remove: Vec<Entity> = combat_system.attackers .iter() .filter_map(|(attacker, attack_data)| { if attack_data.defender == eliminated_player { Some(*attacker) } else { None } }) .collect(); for attacker in attackers_to_remove { combat_system.attackers.remove(&attacker); // Update creature component to no longer be attacking if let Some(mut creature) = commands.get_entity(attacker) { creature.insert(Creature { attacking: None, // Other fields preserved... ..Default::default() // This would be replaced with actual preservation }); } } // Remove any blockers controlled by the eliminated player let blockers_to_remove: Vec<Entity> = combat_system.blockers .iter() .filter_map(|(blocker, _)| { if get_controller(*blocker) == Some(eliminated_player) { Some(*blocker) } else { None } }) .collect(); for blocker in blockers_to_remove { combat_system.blockers.remove(&blocker); // Update creature component to no longer be blocking if let Some(mut creature) = commands.get_entity(blocker) { creature.insert(Creature { blocking: Vec::new(), // Other fields preserved... ..Default::default() // This would be replaced with actual preservation }); } } } } }
Redirect Attack Effects
Some cards can redirect attacks to different players:
#![allow(unused)] fn main() { pub fn handle_attack_redirection( mut combat_system: ResMut<CombatSystem>, redirection_effects: Query<(Entity, &AttackRedirection)>, ) { // Process any attack redirection effects let redirections: Vec<(Entity, Entity)> = combat_system.attackers .iter() .filter_map(|(attacker, attack_data)| { for (_, redirection) in redirection_effects.iter() { if redirection.original_defender == attack_data.defender && redirection.applies_to(*attacker) { return Some((*attacker, redirection.new_defender)); } } None }) .collect(); // Apply redirections for (attacker, new_defender) in redirections { if let Some(attack_data) = combat_system.attackers.get_mut(&attacker) { attack_data.defender = new_defender; } } } #[derive(Component)] pub struct AttackRedirection { pub original_defender: Entity, pub new_defender: Entity, pub condition: RedirectionCondition, } impl AttackRedirection { pub fn applies_to(&self, attacker: Entity) -> bool { match &self.condition { RedirectionCondition::AllAttackers => true, RedirectionCondition::AttackerWithPower { operator, value } => { // Implementation details omitted true }, // Other conditions... _ => false, } } } }
Testing Strategy
Multiplayer Combat Unit Tests
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn test_multiple_attack_targets() { let mut app = App::new(); app.add_systems(Update, validate_multiplayer_attacks); app.add_event::<GameEvent>(); // Set up test environment let active_player = app.world.spawn(Player::default()).id(); let opponent1 = app.world.spawn(Player::default()).id(); let opponent2 = app.world.spawn(Player::default()).id(); let attacker1 = app.world.spawn(Creature::default()).id(); let attacker2 = app.world.spawn(Creature::default()).id(); // Create turn manager let mut turn_manager = TurnManager::default(); turn_manager.active_player_index = 0; turn_manager.player_order = vec![active_player, opponent1, opponent2]; app.insert_resource(turn_manager); // Create combat system with attacks against multiple players let mut combat_system = CombatSystem::default(); combat_system.attackers.insert(attacker1, AttackData { attacker: attacker1, defender: opponent1, is_commander: false, requirements: Vec::new(), restrictions: Vec::new(), }); combat_system.attackers.insert(attacker2, AttackData { attacker: attacker2, defender: opponent2, is_commander: false, requirements: Vec::new(), restrictions: Vec::new(), }); app.insert_resource(combat_system); // Run the system app.update(); // Check for multiplay attack event let mut event_reader = app.world.resource_mut::<Events<GameEvent>>(); let mut found_event = false; for event in event_reader.iter() { if let GameEvent::MultiplayerAttacksDeclared { .. } = event { found_event = true; break; } } assert!(found_event, "Multiplayer attack event not emitted"); } #[test] fn test_goad_mechanic() { let mut app = App::new(); app.add_systems(Update, apply_goad_requirements); // Set up test environment let active_player = app.world.spawn(Player::default()).id(); let opponent1 = app.world.spawn(Player::default()).id(); let opponent2 = app.world.spawn(Player::default()).id(); // Create goaded creature let goaded_creature = app.world.spawn(( Creature::default(), Controllable { controller: active_player }, Goaded { source: opponent1, until_end_of_turn: true }, )).id(); // Create turn manager let mut turn_manager = TurnManager::default(); turn_manager.active_player_index = 0; turn_manager.player_order = vec![active_player, opponent1, opponent2]; app.insert_resource(turn_manager); // Create combat system let combat_system = CombatSystem::default(); app.insert_resource(combat_system); // Run the system app.update(); // Verify goad requirements were applied let combat_system = app.world.resource::<CombatSystem>(); // Check that creature must attack let has_must_attack = combat_system.attack_requirements.get(&goaded_creature) .map_or(false, |reqs| reqs.iter().any(|req| matches!(req, AttackRequirement::MustAttack))); assert!(has_must_attack, "Goaded creature should have MustAttack requirement"); // Check that creature can't attack goad source let has_cant_attack_source = combat_system.attack_restrictions.get(&goaded_creature) .map_or(false, |reqs| reqs.iter().any(|req| matches!(req, AttackRestriction::CantAttackPlayer(p) if *p == opponent1))); assert!(has_cant_attack_source, "Goaded creature should not be able to attack goad source"); } #[test] fn test_monarch_mechanic() { let mut app = App::new(); app.add_systems(Update, monarch_attack_trigger); app.add_event::<GameEvent>(); // Set up test environment let player1 = app.world.spawn(Player::default()).id(); let player2 = app.world.spawn(Player::default()).id(); let attacker = app.world.spawn(( Creature::default(), Controllable { controller: player2 }, )).id(); // Create turn manager with combat phase let mut turn_manager = TurnManager::default(); turn_manager.current_phase = Phase::Combat(CombatStep::CombatDamage); app.insert_resource(turn_manager); // Set player1 as monarch app.insert_resource(Monarch(Some(player1))); // Create combat system with attack history let mut combat_system = CombatSystem::default(); combat_system.attackers.insert(attacker, AttackData { attacker, defender: player1, is_commander: false, requirements: Vec::new(), restrictions: Vec::new(), }); combat_system.combat_history.push_back(CombatEvent::DamageDealt { source: attacker, target: player1, amount: 3, is_commander_damage: false, }); app.insert_resource(combat_system); // Run the system app.update(); // Verify monarch changed let monarch = app.world.resource::<Monarch>(); assert_eq!(monarch.0, Some(player2), "Monarch should have changed to player2"); } // Additional tests... } }
Multiplayer Combat Integration Tests
#![allow(unused)] fn main() { #[cfg(test)] mod integration_tests { use super::*; #[test] fn test_multiplayer_combat_sequence() { let mut builder = CombatScenarioBuilder::new(); // Add multiple opponents let opponent1 = builder.add_player(); let opponent2 = builder.add_player(); // Add attackers for active player let attacker1 = builder.add_attacker(3, 3, builder.active_player, false); let attacker2 = builder.add_attacker(2, 2, builder.active_player, true); // Commander // Add blocker for first opponent let blocker = builder.add_blocker(2, 2, opponent1); // Declare attacks against multiple opponents builder.declare_attacks(vec![ (attacker1, opponent1), (attacker2, opponent2), ]); // Declare blocks builder.declare_blocks(vec![ (blocker, vec![attacker1]), ]); // Execute combat let result = builder.execute(); // Verify results // Opponent1 should have lost no life (blocked) but blocker should be dead assert_eq!(result.player_life[&opponent1], 40); let blocker_status = result.creature_status.get(&blocker).unwrap(); assert!(blocker_status.destroyed); // Opponent2 should have taken commander damage assert_eq!(result.player_life[&opponent2], 40 - 2); assert_eq!(result.commander_damage[&opponent2][&attacker2], 2); } #[test] fn test_player_elimination_during_combat() { let mut app = App::new(); // Setup full game environment with multiple players // Implementation details omitted for brevity // Simulate combat damage that eliminates a player // Implementation details omitted for brevity // Verify that all attacks targeting the eliminated player are removed // Implementation details omitted for brevity } // Additional integration tests... } }
Multiplayer Combat System Tests
#![allow(unused)] fn main() { #[cfg(test)] mod system_tests { use super::*; #[test] fn test_full_multiplayer_game() { let mut app = App::new(); // Setup a 4-player Commander game let players = setup_four_player_game(&mut app); // Play through several turns with complex political interactions for _ in 0..10 { app.update(); // Add various political actions between updates // (goad effects, attack redirection, monarch changes, etc.) // Implementation details omitted for brevity } // Verify multiplayer combat interactions worked as expected // Implementation details omitted for brevity } // Helper functions for system tests fn setup_four_player_game(app: &mut App) -> Vec<Entity> { // Implementation details omitted for brevity Vec::new() } // Additional system tests... } }
Performance Considerations
Multiplayer combat introduces additional computational complexity:
#![allow(unused)] fn main() { // Optimize attack target lookup pub fn optimize_multiplayer_combat( mut combat_system: ResMut<CombatSystem>, ) { // Build attack target lookup table for faster validation let mut attack_target_map: HashMap<Entity, HashSet<Entity>> = HashMap::new(); for (attacker, attack_data) in &combat_system.attackers { attack_target_map.entry(attack_data.defender) .or_insert_with(HashSet::new) .insert(*attacker); } combat_system.attack_target_map = attack_target_map; } }
UI Considerations
Multiplayer combat requires special attention to the user interface:
#![allow(unused)] fn main() { pub fn update_multiplayer_combat_ui( combat_system: Res<CombatSystem>, player_query: Query<(Entity, &Player)>, mut ui_state: ResMut<UiState>, ) { // Group attacks by defender for UI display let mut attacks_by_defender: HashMap<Entity, Vec<Entity>> = HashMap::new(); for (attacker, attack_data) in &combat_system.attackers { attacks_by_defender.entry(attack_data.defender) .or_insert_with(Vec::new) .push(*attacker); } // Update UI state for each player for (player_entity, _) in player_query.iter() { // Display incoming attacks for this player if let Some(attackers) = attacks_by_defender.get(&player_entity) { ui_state.player_incoming_attacks.insert(player_entity, attackers.clone()); } else { ui_state.player_incoming_attacks.insert(player_entity, Vec::new()); } } } }
Networking Considerations
In a networked multiplayer game, commander-specific combat events need special handling:
#![allow(unused)] fn main() { pub fn replicate_multiplayer_combat_state( combat_system: Res<CombatSystem>, monarch: Res<Monarch>, mut replication: ResMut<Replication>, ) { // Replicate critical combat state replication.replicate_resource::<CombatSystem>(); replication.replicate_resource::<Monarch>(); // Replicate all combat-relevant components for (entity, _) in combat_system.attackers.iter() { replication.replicate_entity(*entity); } for (entity, _) in combat_system.blockers.iter() { replication.replicate_entity(*entity); } } }
Conclusion
Multiplayer combat in Commander adds significant complexity but also creates rich strategic gameplay. By properly implementing the systems described in this document, we ensure that players can fully experience the political dynamics and multiplayer interactions that make Commander such a beloved format. The implementation handles all the edge cases and unique mechanics while maintaining good performance even with four or more players.
Combat System
Overview
The Combat System is a critical component of the Commander game engine, handling all aspects of creature combat including attacks, blocks, damage assignment, and combat-specific abilities. This system is especially complex in Commander due to the multiplayer nature of the format and the special rules regarding commander damage.
Core Components
The combat system consists of several interconnected components:
- Combat Phase Management - Handling the flow through combat steps
- Attack Declaration System - Managing which creatures attack and who they attack
- Block Declaration System - Managing how defending players assign blockers
- Damage Assignment System - Calculating and applying combat damage
- Commander Damage Tracking - Monitoring and accumulating commander damage
- Combat Triggers - Handling abilities that trigger during combat
System Architecture
The combat system uses a modular design pattern to separate concerns:
#![allow(unused)] fn main() { // Core combat system resource #[derive(Resource)] pub struct CombatSystem { pub active_combat_step: Option<CombatStep>, pub attackers: HashMap<Entity, AttackData>, pub blockers: HashMap<Entity, BlockData>, pub combat_triggers: Vec<CombatTrigger>, pub damage_assignment_order: Vec<Entity>, pub combat_history: VecDeque<CombatEvent>, } // Data for an attacking creature #[derive(Debug, Clone)] pub struct AttackData { pub attacker: Entity, pub defender: Entity, // Can be a player or planeswalker pub is_commander: bool, pub requirements: Vec<AttackRequirement>, pub restrictions: Vec<AttackRestriction>, } // Data for a blocking creature #[derive(Debug, Clone)] pub struct BlockData { pub blocker: Entity, pub blocked_attackers: Vec<Entity>, pub requirements: Vec<BlockRequirement>, pub restrictions: Vec<BlockRestriction>, } // Combat event for history tracking #[derive(Debug, Clone)] pub enum CombatEvent { BeginCombat { turn: u32, active_player: Entity }, AttackDeclared { attacker: Entity, defender: Entity }, BlockDeclared { blocker: Entity, attacker: Entity }, DamageDealt { source: Entity, target: Entity, amount: u32, is_commander_damage: bool }, CombatEnded { turn: u32 }, } }
Integration with Other Systems
The combat system interfaces with several other game systems:
- Turn Manager - For phase control and player ordering
- Stack System - For handling combat-triggered abilities
- Damage System - For applying damage and handling prevention/redirection effects
- Game State System - For tracking changes to the game state during combat
- Player System - For player state changes (life totals, commander damage)
Documentation Structure
This documentation is organized into several parts:
- Combat Phases - Detailed implementation of each combat step
- Commander Damage - Special handling of commander damage
- Multiplayer Combat - Combat in a multiplayer environment
- Combat Abilities - Implementation of combat-specific abilities
- Combat Verification - Testing and verification approach
Each section contains detailed information about implementation, edge cases, and testing strategies for that aspect of the combat system.
Combat Verification
Overview
A robust verification framework is critical for ensuring that the Combat System correctly implements the complex rules of Magic: The Gathering's Commander format. This document outlines our comprehensive testing approach, which covers unit testing, integration testing, system testing, and end-to-end testing strategies to verify both basic functionality and edge cases.
Testing Framework Architecture
#![allow(unused)] fn main() { // Test utility for setting up combat scenarios pub struct CombatScenarioBuilder { pub app: App, pub active_player: Entity, pub players: Vec<Entity>, pub attackers: Vec<Entity>, pub blockers: Vec<Entity>, pub effects: Vec<(Entity, Effect)>, } impl CombatScenarioBuilder { pub fn new() -> Self { let mut app = App::new(); // Add essential plugins and systems app.add_plugins(MinimalPlugins) .add_plugins(TestTurnSystemPlugin) .add_plugins(TestCombatSystemPlugin); // Initialize resources app.insert_resource(CombatSystem::default()); app.insert_resource(TurnManager::default()); // Create default active player let active_player = app.world.spawn(Player { life_total: 40, commander_damage: HashMap::new(), ..Default::default() }).id(); Self { app, active_player, players: vec![active_player], attackers: Vec::new(), blockers: Vec::new(), effects: Vec::new(), } } // Add a player to the scenario pub fn add_player(&mut self) -> Entity { let player = self.app.world.spawn(Player { life_total: 40, commander_damage: HashMap::new(), ..Default::default() }).id(); self.players.push(player); player } // Add an attacking creature pub fn add_attacker(&mut self, power: u32, toughness: u32, controller: Entity, is_commander: bool) -> Entity { let mut components = ( Card::default(), Creature { power, toughness, attacking: None, blocking: Vec::new(), ..Default::default() }, Controllable { controller }, ); let entity = if is_commander { self.app.world.spawn((components, Commander)).id() } else { self.app.world.spawn(components).id() }; self.attackers.push(entity); entity } // Add a blocking creature pub fn add_blocker(&mut self, power: u32, toughness: u32, controller: Entity) -> Entity { let entity = self.app.world.spawn(( Card::default(), Creature { power, toughness, attacking: None, blocking: Vec::new(), ..Default::default() }, Controllable { controller }, )).id(); self.blockers.push(entity); entity } // Add an effect to an entity pub fn add_effect(&mut self, entity: Entity, effect: Effect) { self.effects.push((entity, effect)); // Apply the effect to the entity let mut effects = self.app.world.entity_mut(entity) .get_mut::<ActiveEffects>() .unwrap_or_else(|| { self.app.world.entity_mut(entity).insert(ActiveEffects(Vec::new())); self.app.world.entity_mut(entity).get_mut::<ActiveEffects>().unwrap() }); effects.0.push(effect); } // Set up attacks pub fn declare_attacks(&mut self, attacks: Vec<(Entity, Entity)>) { let mut combat_system = self.app.world.resource_mut::<CombatSystem>(); for (attacker, defender) in attacks { // Update creature component let mut attacker_entity = self.app.world.entity_mut(attacker); let mut creature = attacker_entity.get_mut::<Creature>().unwrap(); creature.attacking = Some(defender); // Check if attacker is a commander let is_commander = attacker_entity.contains::<Commander>(); // Add to combat system combat_system.attackers.insert(attacker, AttackData { attacker, defender, is_commander, requirements: Vec::new(), restrictions: Vec::new(), }); } } // Set up blocks pub fn declare_blocks(&mut self, blocks: Vec<(Entity, Vec<Entity>)>) { let mut combat_system = self.app.world.resource_mut::<CombatSystem>(); for (blocker, blocked_attackers) in blocks { // Update creature component let mut blocker_entity = self.app.world.entity_mut(blocker); let mut creature = blocker_entity.get_mut::<Creature>().unwrap(); creature.blocking = blocked_attackers.clone(); // Add to combat system combat_system.blockers.insert(blocker, BlockData { blocker, blocked_attackers, requirements: Vec::new(), restrictions: Vec::new(), }); } } // Advance to a specific combat step pub fn advance_to(&mut self, step: CombatStep) { let mut turn_manager = self.app.world.resource_mut::<TurnManager>(); turn_manager.current_phase = Phase::Combat(step); let mut combat_system = self.app.world.resource_mut::<CombatSystem>(); combat_system.active_combat_step = Some(step); } // Execute combat and return results pub fn execute(&mut self) -> CombatResult { // Run the relevant systems based on the current step match self.app.world.resource::<CombatSystem>().active_combat_step { Some(CombatStep::Beginning) => { self.app.update_with_system(beginning_of_combat_system); } Some(CombatStep::DeclareAttackers) => { self.app.update_with_system(declare_attackers_system); } Some(CombatStep::DeclareBlockers) => { self.app.update_with_system(declare_blockers_system); } Some(CombatStep::FirstStrike) => { self.app.update_with_system(first_strike_damage_system); } Some(CombatStep::CombatDamage) => { self.app.update_with_system(apply_combat_damage_system); } Some(CombatStep::End) => { self.app.update_with_system(end_of_combat_system); } None => { // Run all systems in sequence self.advance_to(CombatStep::Beginning); self.app.update_with_system(beginning_of_combat_system); self.advance_to(CombatStep::DeclareAttackers); self.app.update_with_system(declare_attackers_system); self.advance_to(CombatStep::DeclareBlockers); self.app.update_with_system(declare_blockers_system); // Check if first strike is needed if self.has_first_strike_creatures() { self.advance_to(CombatStep::FirstStrike); self.app.update_with_system(first_strike_damage_system); } self.advance_to(CombatStep::CombatDamage); self.app.update_with_system(apply_combat_damage_system); self.advance_to(CombatStep::End); self.app.update_with_system(end_of_combat_system); } } // Collect and return results self.collect_combat_results() } // Helper to check if any creatures have first strike fn has_first_strike_creatures(&self) -> bool { for attacker in &self.attackers { if let Ok(creature) = self.app.world.entity(*attacker).get::<Creature>() { if creature.has_ability(Ability::Keyword(Keyword::FirstStrike)) || creature.has_ability(Ability::Keyword(Keyword::DoubleStrike)) { return true; } } } for blocker in &self.blockers { if let Ok(creature) = self.app.world.entity(*blocker).get::<Creature>() { if creature.has_ability(Ability::Keyword(Keyword::FirstStrike)) || creature.has_ability(Ability::Keyword(Keyword::DoubleStrike)) { return true; } } } false } // Collect results of combat fn collect_combat_results(&self) -> CombatResult { let mut result = CombatResult::default(); // Collect player life totals and commander damage for player in &self.players { if let Ok(player_component) = self.app.world.entity(*player).get::<Player>() { result.player_life.insert(*player, player_component.life_total); result.commander_damage.insert(*player, player_component.commander_damage.clone()); } } // Collect creature status for creature in self.attackers.iter().chain(self.blockers.iter()) { if let Ok(creature_component) = self.app.world.entity(*creature).get::<Creature>() { result.creature_status.insert(*creature, CreatureStatus { power: creature_component.power, toughness: creature_component.toughness, damage: creature_component.damage, destroyed: self.app.world.entity(*creature).contains::<Destroyed>(), tapped: self.app.world.entity(*creature).contains::<Tapped>(), }); } } // Collect combat events let combat_system = self.app.world.resource::<CombatSystem>(); result.combat_events = combat_system.combat_history.clone(); result } } // Structure to hold combat test results #[derive(Default)] pub struct CombatResult { pub player_life: HashMap<Entity, i32>, pub commander_damage: HashMap<Entity, HashMap<Entity, u32>>, pub creature_status: HashMap<Entity, CreatureStatus>, pub combat_events: VecDeque<CombatEvent>, } #[derive(Default)] pub struct CreatureStatus { pub power: u32, pub toughness: u32, pub damage: u32, pub destroyed: bool, pub tapped: bool, } }
Unit Testing
Combat Steps Tests
Each combat step has dedicated unit tests to verify its functionality in isolation:
#![allow(unused)] fn main() { #[cfg(test)] mod beginning_of_combat_tests { use super::*; #[test] fn test_beginning_of_combat_initialization() { let mut builder = CombatScenarioBuilder::new(); // Add some creatures let opponent = builder.add_player(); builder.add_attacker(2, 2, builder.active_player, false); builder.add_attacker(3, 3, builder.active_player, true); // Commander // Set up the beginning of combat step builder.advance_to(CombatStep::Beginning); // Execute and get results let result = builder.execute(); // Verify combat system is properly initialized let combat_system = builder.app.world.resource::<CombatSystem>(); assert_eq!(combat_system.active_combat_step, Some(CombatStep::Beginning)); // Verify begin combat event was recorded assert!(result.combat_events.iter().any(|event| matches!(event, CombatEvent::BeginCombat { .. }))); } // Additional tests... } #[cfg(test)] mod declare_attackers_tests { use super::*; #[test] fn test_declare_attackers_basic() { let mut builder = CombatScenarioBuilder::new(); // Add some creatures and a player to attack let opponent = builder.add_player(); let attacker1 = builder.add_attacker(2, 2, builder.active_player, false); let attacker2 = builder.add_attacker(3, 3, builder.active_player, true); // Commander // Set up attacks builder.advance_to(CombatStep::DeclareAttackers); builder.declare_attacks(vec![ (attacker1, opponent), (attacker2, opponent), ]); // Execute and get results let result = builder.execute(); // Verify creatures are marked as attacking let combat_system = builder.app.world.resource::<CombatSystem>(); assert_eq!(combat_system.attackers.len(), 2); // Verify attack events were recorded assert_eq!( result.combat_events.iter() .filter(|event| matches!(event, CombatEvent::AttackDeclared { .. })) .count(), 2 ); // Verify creatures are tapped for (entity, status) in &result.creature_status { assert!(status.tapped); } } // Additional tests... } // Similar unit tests for other combat steps... }
Edge Case Tests
Dedicated tests for all identified edge cases:
#![allow(unused)] fn main() { #[cfg(test)] mod edge_case_tests { use super::*; #[test] fn test_indestructible_creatures() { let mut builder = CombatScenarioBuilder::new(); // Setup creatures let opponent = builder.add_player(); let attacker = builder.add_attacker(2, 2, builder.active_player, false); let blocker = builder.add_blocker(4, 4, opponent); // Make attacker indestructible builder.add_effect(attacker, Effect::Indestructible); // Set up combat builder.advance_to(CombatStep::DeclareAttackers); builder.declare_attacks(vec![(attacker, opponent)]); builder.advance_to(CombatStep::DeclareBlockers); builder.declare_blocks(vec![(blocker, vec![attacker])]); // Execute full combat builder.advance_to(CombatStep::CombatDamage); let result = builder.execute(); // Verify attacker received lethal damage but wasn't destroyed let attacker_status = result.creature_status.get(&attacker).unwrap(); assert_eq!(attacker_status.damage, 4); // Received damage assert!(!attacker_status.destroyed); // But not destroyed // Verify blocker took damage let blocker_status = result.creature_status.get(&blocker).unwrap(); assert_eq!(blocker_status.damage, 2); assert!(!blocker_status.destroyed); } #[test] fn test_deathtouch() { let mut builder = CombatScenarioBuilder::new(); // Setup creatures let opponent = builder.add_player(); let attacker = builder.add_attacker(1, 1, builder.active_player, false); let blocker = builder.add_blocker(10, 10, opponent); // Add deathtouch to attacker builder.add_effect(attacker, Effect::Keyword(Keyword::Deathtouch)); // Set up combat builder.advance_to(CombatStep::DeclareAttackers); builder.declare_attacks(vec![(attacker, opponent)]); builder.advance_to(CombatStep::DeclareBlockers); builder.declare_blocks(vec![(blocker, vec![attacker])]); // Execute full combat builder.advance_to(CombatStep::CombatDamage); let result = builder.execute(); // Verify blocker was destroyed by deathtouch let blocker_status = result.creature_status.get(&blocker).unwrap(); assert!(blocker_status.destroyed); // Verify attacker was also destroyed let attacker_status = result.creature_status.get(&attacker).unwrap(); assert!(attacker_status.destroyed); } #[test] fn test_trample() { let mut builder = CombatScenarioBuilder::new(); // Setup creatures let opponent = builder.add_player(); let attacker = builder.add_attacker(6, 6, builder.active_player, false); let blocker = builder.add_blocker(2, 2, opponent); // Add trample to attacker builder.add_effect(attacker, Effect::Keyword(Keyword::Trample)); // Set up combat builder.advance_to(CombatStep::DeclareAttackers); builder.declare_attacks(vec![(attacker, opponent)]); builder.advance_to(CombatStep::DeclareBlockers); builder.declare_blocks(vec![(blocker, vec![attacker])]); // Execute full combat builder.advance_to(CombatStep::CombatDamage); let result = builder.execute(); // Verify opponent took trample damage assert_eq!(result.player_life[&opponent], 40 - 4); // 6 power - 2 toughness = 4 damage // Verify blocker was destroyed let blocker_status = result.creature_status.get(&blocker).unwrap(); assert!(blocker_status.destroyed); } #[test] fn test_multiple_blockers() { let mut builder = CombatScenarioBuilder::new(); // Setup creatures let opponent = builder.add_player(); let attacker = builder.add_attacker(5, 5, builder.active_player, false); let blocker1 = builder.add_blocker(2, 2, opponent); let blocker2 = builder.add_blocker(2, 2, opponent); // Set up combat builder.advance_to(CombatStep::DeclareAttackers); builder.declare_attacks(vec![(attacker, opponent)]); builder.advance_to(CombatStep::DeclareBlockers); builder.declare_blocks(vec![ (blocker1, vec![attacker]), (blocker2, vec![attacker]), ]); // Add damage assignment order let mut combat_system = builder.app.world.resource_mut::<CombatSystem>(); combat_system.damage_assignment_order = vec![blocker1, blocker2]; // Execute full combat builder.advance_to(CombatStep::CombatDamage); let result = builder.execute(); // Verify both blockers were destroyed let blocker1_status = result.creature_status.get(&blocker1).unwrap(); assert!(blocker1_status.destroyed); let blocker2_status = result.creature_status.get(&blocker2).unwrap(); assert!(blocker2_status.destroyed); // Verify attacker took damage from both blockers let attacker_status = result.creature_status.get(&attacker).unwrap(); assert_eq!(attacker_status.damage, 4); assert!(!attacker_status.destroyed); // Verify player took no damage assert_eq!(result.player_life[&opponent], 40); } // Many more edge case tests... } }
Integration Tests
Integration tests verify that different parts of the combat system work together correctly:
#![allow(unused)] fn main() { #[cfg(test)] mod integration_tests { use super::*; #[test] fn test_full_combat_sequence() { let mut builder = CombatScenarioBuilder::new(); // Setup a complex combat scenario let opponent = builder.add_player(); let attacker1 = builder.add_attacker(3, 3, builder.active_player, false); let attacker2 = builder.add_attacker(4, 4, builder.active_player, true); // Commander let blocker1 = builder.add_blocker(2, 2, opponent); let blocker2 = builder.add_blocker(5, 5, opponent); // Add some abilities builder.add_effect(attacker1, Effect::Keyword(Keyword::Trample)); builder.add_effect(blocker2, Effect::Keyword(Keyword::Deathtouch)); // Run through the entire combat sequence builder.declare_attacks(vec![ (attacker1, opponent), (attacker2, opponent), ]); builder.declare_blocks(vec![ (blocker1, vec![attacker1]), (blocker2, vec![attacker2]), ]); // Execute and get results let result = builder.execute(); // Verify final state // Attacker1 should have dealt trample damage assert_eq!(result.player_life[&opponent], 40 - 1); // 3 power - 2 toughness = 1 damage // Attacker2 (commander) should be destroyed by deathtouch let attacker2_status = result.creature_status.get(&attacker2).unwrap(); assert!(attacker2_status.destroyed); // Blocker1 should be destroyed let blocker1_status = result.creature_status.get(&blocker1).unwrap(); assert!(blocker1_status.destroyed); // Blocker2 should take damage but survive let blocker2_status = result.creature_status.get(&blocker2).unwrap(); assert_eq!(blocker2_status.damage, 4); assert!(!blocker2_status.destroyed); // Commander damage should be tracked assert_eq!(result.commander_damage[&opponent][&attacker2], 4); } #[test] fn test_complex_combat_with_triggers() { // Test implementation omitted for brevity // This would test combat with abilities that trigger on attack, block, or damage } #[test] fn test_multiple_turns_commander_damage() { // Test that tracks commander damage across multiple turns // Implementation omitted for brevity } // Additional integration tests... } }
System Tests
System tests verify the combat system's interaction with other systems:
#![allow(unused)] fn main() { #[cfg(test)] mod system_tests { use super::*; #[test] fn test_combat_with_stack_interaction() { let mut app = App::new(); // Add complete game systems app.add_plugins(MinimalPlugins) .add_plugins(GameLogicPlugins); // Set up test entities and start a game // Implementation omitted for brevity // Create a combat scenario with abilities that go on the stack // Implementation omitted for brevity // Run the game until combat is complete for _ in 0..20 { // Limit iterations to prevent infinite loops app.update(); // Check if combat is complete let combat_system = app.world.resource::<CombatSystem>(); if combat_system.active_combat_step == Some(CombatStep::End) { break; } } // Verify stack interactions worked correctly // Implementation omitted for brevity } #[test] fn test_combat_with_state_changes() { // Test that combat properly interacts with game state changes // Implementation omitted for brevity } // Additional system tests... } }
End-to-End Tests
End-to-end tests verify the combat system in the context of a complete game:
#![allow(unused)] fn main() { #[cfg(test)] mod e2e_tests { use super::*; #[test] fn test_complete_game_with_combat() { let mut app = App::new(); // Add complete game plugins app.add_plugins(DefaultPlugins) .add_plugins(GamePlugins); // Set up a complete game with multiple players setup_complete_game(&mut app, 4); // 4 players // Play through several turns for _ in 0..10 { // 10 turns play_turn(&mut app); } // Verify game state let game_state = app.world.resource::<GameState>(); // Check remaining players let player_query = app.world.query_filtered::<Entity, With<Player>>(); let remaining_players = player_query.iter(&app.world).count(); // In most cases, we should still have multiple players assert!(remaining_players > 1, "Game ended too quickly"); // Verify commander damage has been dealt let player_query = app.world.query::<&Player>(); let has_commander_damage = player_query .iter(&app.world) .any(|player| !player.commander_damage.is_empty()); assert!(has_commander_damage, "No commander damage was dealt in the game"); } #[test] fn test_commander_damage_victory() { // Test a game where a player wins via commander damage // Implementation omitted for brevity } // Helper function to play a single turn fn play_turn(app: &mut App) { // Get current phase let turn_manager = app.world.resource::<TurnManager>(); let start_phase = turn_manager.current_phase; // Run updates until we complete a turn for _ in 0..100 { // Limit iterations to prevent infinite loops app.update(); let turn_manager = app.world.resource::<TurnManager>(); // If we've come back to the same phase, we've completed a turn if turn_manager.current_phase == start_phase && turn_manager.active_player_index != turn_manager.active_player_index { break; } } } // Helper function to set up a complete game fn setup_complete_game(app: &mut App, num_players: usize) { // Implementation omitted for brevity } // Additional end-to-end tests... } }
Property-Based Testing
We also employ property-based testing to verify invariants across randomized scenarios:
#![allow(unused)] fn main() { #[cfg(test)] mod property_tests { use super::*; use proptest::prelude::*; proptest! { #[test] fn commander_damage_consistency( // Generate random commander power between 1 and 10 commander_power in 1u32..10, // Generate random number of combat rounds between 1 and 5 combat_rounds in 1usize..5 ) { // Setup combat scenario with commander of specified power let mut builder = CombatScenarioBuilder::new(); let opponent = builder.add_player(); let commander = builder.add_attacker(commander_power, commander_power, builder.active_player, true); // Track expected damage let mut expected_damage = 0; // Simulate multiple rounds of combat for _ in 0..combat_rounds { builder.advance_to(CombatStep::DeclareAttackers); builder.declare_attacks(vec![(commander, opponent)]); // No blocks for simplicity builder.advance_to(CombatStep::DeclareBlockers); // Execute combat damage builder.advance_to(CombatStep::CombatDamage); builder.execute(); // Update expected damage expected_damage += commander_power; } // Check final state let result = builder.collect_combat_results(); // Verify commander damage matches expected total prop_assert_eq!( result.commander_damage[&opponent][&commander], expected_damage ); // Verify life total reflects damage prop_assert_eq!( result.player_life[&opponent], 40 - (expected_damage as i32) ); } #[test] fn multiple_attackers_damage_distribution( // Generate 1-5 attackers with power 1-5 each attackers in prop::collection::vec((1u32..5, 1u32..5), 1..5) ) { let mut builder = CombatScenarioBuilder::new(); let opponent = builder.add_player(); // Create attackers and track total power let mut attacker_entities = Vec::new(); let mut total_power = 0; for (power, toughness) in attackers { let attacker = builder.add_attacker( power, toughness, builder.active_player, false); attacker_entities.push(attacker); total_power += power; } // Declare all attackers attacking opponent builder.advance_to(CombatStep::DeclareAttackers); builder.declare_attacks( attacker_entities.iter() .map(|&a| (a, opponent)) .collect() ); // No blocks builder.advance_to(CombatStep::DeclareBlockers); // Execute combat damage builder.advance_to(CombatStep::CombatDamage); let result = builder.execute(); // Verify opponent's life total correctly reflects all damage prop_assert_eq!( result.player_life[&opponent], 40 - (total_power as i32) ); } } } }
Test Coverage Goals
Our testing strategy aims for the following coverage metrics:
- Line Coverage: At least 90% of all combat-related code
- Branch Coverage: At least 85% of all conditional branches
- Path Coverage: At least 75% of all execution paths
- Edge Case Coverage: 100% of identified edge cases have dedicated tests
Continuous Integration
All combat tests are integrated into the CI pipeline with the following workflow:
- Unit tests run on every commit
- Integration tests run on every PR
- System and end-to-end tests run nightly
- Coverage reports generated after each test run
Debugging Tools
To assist with debugging, we provide specialized tools:
#![allow(unused)] fn main() { /// Utility for debugging combat scenarios pub fn debug_combat_state(combat_system: &CombatSystem, world: &World) { println!("=== COMBAT DEBUG STATE ==="); // Print current combat step println!("Combat Step: {:?}", combat_system.active_combat_step); // Print attackers println!("\nAttackers:"); for (entity, attack_data) in &combat_system.attackers { if let Ok((_, creature, _)) = world.query::<(Entity, &Creature, Option<&Commander>)>().get(world, *entity) { println!(" {:?} ({}:{}) attacking {:?}{}", entity, creature.power, creature.toughness, attack_data.defender, if attack_data.is_commander { " [COMMANDER]" } else { "" } ); } } // Print blockers println!("\nBlockers:"); for (entity, block_data) in &combat_system.blockers { if let Ok((_, creature)) = world.query::<(Entity, &Creature)>().get(world, *entity) { println!(" {:?} ({}:{}) blocking: {:?}", entity, creature.power, creature.toughness, block_data.blocked_attackers ); } } // Print combat history println!("\nCombat History:"); for (i, event) in combat_system.combat_history.iter().enumerate() { println!(" [{}] {:?}", i, event); } println!("========================="); } }
Verification Strategy Evolution
Our verification approach is not static; it evolves over time:
- New edge cases identified during testing or gameplay are added to the test suite
- Performance bottlenecks identified through testing are addressed
- Test frameworks are updated as the combat system evolves
- Regression tests are added for any bugs found in production
By following this comprehensive verification strategy, we ensure the Commander combat system correctly implements all rules, handles edge cases properly, and provides a robust foundation for the game engine.
Beginning of Combat Step
Overview
The Beginning of Combat step is the first step of the Combat Phase in Magic: The Gathering. This step serves as a transition between the pre-combat Main Phase and the declaration of attackers. It provides a crucial window for players to cast instants and activate abilities before attackers are declared. This document details the implementation of the Beginning of Combat step in our Commander game engine.
Core Implementation
Phase Structure
The Beginning of Combat step is implemented as part of the overall combat phase system:
#![allow(unused)] fn main() { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CombatStep { BeginningOfCombat, DeclareAttackers, DeclareBlockers, CombatDamage, EndOfCombat, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Phase { // Other phases... Combat(CombatStep), // Other phases... } }
Beginning of Combat System
The core system that handles the Beginning of Combat step:
#![allow(unused)] fn main() { pub fn beginning_of_combat_system( mut commands: Commands, turn_manager: Res<TurnManager>, mut game_events: EventWriter<GameEvent>, active_player: Query<Entity, With<ActivePlayer>>, mut next_phase: ResMut<NextState<Phase>>, mut priority_system: ResMut<PrioritySystem>, ) { // Only run during Beginning of Combat if turn_manager.current_phase != Phase::Combat(CombatStep::BeginningOfCombat) { return; } // If this is the first time entering the step if !priority_system.priority_given { // Emit Beginning of Combat event let active_player_entity = active_player.single(); game_events.send(GameEvent::BeginningOfCombat { player: active_player_entity, }); // Clear any "until end of combat" effects from previous turns commands.run_system(clear_end_of_combat_effects); // Initialize any tracked combat data commands.insert_resource(CombatData::default()); // Grant priority to active player priority_system.grant_initial_priority(); } // If all players have passed priority without adding to the stack if priority_system.all_players_passed() && !priority_system.stack_changed { // Proceed to Declare Attackers step next_phase.set(Phase::Combat(CombatStep::DeclareAttackers)); priority_system.priority_given = false; } } }
Event Triggers
The beginning of combat step triggers various abilities:
#![allow(unused)] fn main() { pub fn beginning_of_combat_triggers( turn_manager: Res<TurnManager>, mut ability_triggers: ResMut<AbilityTriggerQueue>, trigger_sources: Query<(Entity, &AbilityTrigger)>, ) { // Only run during Beginning of Combat if turn_manager.current_phase != Phase::Combat(CombatStep::BeginningOfCombat) { return; } // Process all "at beginning of combat" triggers for (entity, trigger) in trigger_sources.iter() { if let TriggerCondition::BeginningOfCombat { controller_only } = trigger.condition { let should_trigger = if controller_only { // Only trigger for the active player's permanents // Implementation details omitted true } else { // Trigger for all permanents with this trigger true }; if should_trigger { ability_triggers.queue.push_back(AbilityTriggerEvent { source: entity, trigger: trigger.clone(), targets: Vec::new(), // Will be filled later in target selection }); } } } } }
Multiplayer Considerations
In Commander, the Beginning of Combat step needs to handle multiplayer-specific considerations:
#![allow(unused)] fn main() { pub fn multiplayer_beginning_of_combat( turn_manager: Res<TurnManager>, player_query: Query<(Entity, &Player)>, mut game_events: EventWriter<GameEvent>, ) { // Only run during Beginning of Combat if turn_manager.current_phase != Phase::Combat(CombatStep::BeginningOfCombat) { return; } // Notify all players about the beginning of combat let active_player = turn_manager.get_active_player(); // Broadcast to all players for (player_entity, _) in player_query.iter() { game_events.send(GameEvent::PhaseChange { phase: Phase::Combat(CombatStep::BeginningOfCombat), player: active_player, notification_target: player_entity, }); } } }
Ability Types in Beginning of Combat
Static Abilities
Static abilities that specifically affect combat are evaluated during this step:
#![allow(unused)] fn main() { pub fn evaluate_combat_static_abilities( mut commands: Commands, turn_manager: Res<TurnManager>, static_ability_query: Query<(Entity, &StaticAbility)>, creature_query: Query<(Entity, &Creature, &Controllable)>, ) { // Only run during Beginning of Combat if turn_manager.current_phase != Phase::Combat(CombatStep::BeginningOfCombat) { return; } // Apply "can't attack" effects for (entity, static_ability) in static_ability_query.iter() { match static_ability.effect { StaticEffect::CantAttack { target, condition } => { // Apply can't attack markers to appropriate creatures for (creature_entity, _, controllable) in creature_query.iter() { if target.matches(creature_entity) && condition.is_met(creature_entity) { commands.entity(creature_entity).insert(CantAttack { source: entity, duration: static_ability.duration, }); } } }, StaticEffect::CantBlock { target, condition } => { // Apply can't block markers to appropriate creatures for (creature_entity, _, controllable) in creature_query.iter() { if target.matches(creature_entity) && condition.is_met(creature_entity) { commands.entity(creature_entity).insert(CantBlock { source: entity, duration: static_ability.duration, }); } } }, // Other combat-relevant static effects... _ => {} } } } }
Triggered Abilities
Abilities that trigger at the beginning of combat:
#![allow(unused)] fn main() { #[derive(Component)] pub struct AbilityTrigger { pub condition: TriggerCondition, pub effect: TriggeredEffect, } #[derive(Clone)] pub enum TriggerCondition { BeginningOfCombat { controller_only: bool }, // Other trigger conditions... } // Example of a "beginning of combat" triggered ability pub fn assemble_megatron_trigger( turn_manager: Res<TurnManager>, megatron_query: Query<(Entity, &Controllable), With<Megatron>>, mut game_events: EventWriter<GameEvent>, mut ability_triggers: ResMut<AbilityTriggerQueue>, ) { // Only run during Beginning of Combat if turn_manager.current_phase != Phase::Combat(CombatStep::BeginningOfCombat) { return; } let active_player = turn_manager.get_active_player(); for (entity, controllable) in megatron_query.iter() { // Only trigger for the active player's Megatron if controllable.controller == active_player { ability_triggers.queue.push_back(AbilityTriggerEvent { source: entity, trigger: AbilityTrigger { condition: TriggerCondition::BeginningOfCombat { controller_only: true }, effect: TriggeredEffect::AssembleMegatron, }, targets: Vec::new(), }); } } } }
Edge Cases and Special Interactions
Cleanup Actions
Sometimes effects need to be cleaned up at the beginning of combat:
#![allow(unused)] fn main() { pub fn clear_end_of_combat_effects( mut commands: Commands, effect_query: Query<(Entity, &EndOfCombatEffect)>, ) { for (entity, effect) in effect_query.iter() { if effect.cleanup_at_next_beginning_of_combat { // Remove the effect component commands.entity(entity).remove::<EndOfCombatEffect>(); // Apply any cleanup logic specific to this effect match effect.effect_type { EndOfCombatEffectType::TemporaryPowerBoost => { commands.entity(entity).remove::<PowerToughnessModifier>(); }, EndOfCombatEffectType::TemporaryAbilityGrant => { commands.entity(entity).remove::<GrantedAbility>(); }, // Other effect types... } } } } }
"Until Your Next Combat" Effects
Some effects last until a player's next combat phase begins:
#![allow(unused)] fn main() { pub fn process_until_next_combat_effects( mut commands: Commands, turn_manager: Res<TurnManager>, effect_query: Query<(Entity, &UntilNextCombatEffect, &Controllable)>, ) { // Only run during Beginning of Combat if turn_manager.current_phase != Phase::Combat(CombatStep::BeginningOfCombat) { return; } let active_player = turn_manager.get_active_player(); // Find and remove effects that should end for (entity, effect, controllable) in effect_query.iter() { if controllable.controller == active_player { commands.entity(entity).remove::<UntilNextCombatEffect>(); // Apply any cleanup logic specific to this effect match effect.effect_type { // Implementation details... _ => {} } } } } }
Testing Strategy
Unit Tests
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn test_beginning_of_combat_triggers() { let mut app = App::new(); app.add_systems(Update, beginning_of_combat_triggers); app.init_resource::<AbilityTriggerQueue>(); // Create a simple trigger let trigger_entity = app.world.spawn(( AbilityTrigger { condition: TriggerCondition::BeginningOfCombat { controller_only: false }, effect: TriggeredEffect::DrawCard { count: 1 }, }, )).id(); // Set up turn manager with Beginning of Combat phase let mut turn_manager = TurnManager::default(); turn_manager.current_phase = Phase::Combat(CombatStep::BeginningOfCombat); app.insert_resource(turn_manager); // Run the system app.update(); // Check if trigger was added to queue let ability_triggers = app.world.resource::<AbilityTriggerQueue>(); assert_eq!(ability_triggers.queue.len(), 1); let trigger_event = ability_triggers.queue.front().unwrap(); assert_eq!(trigger_event.source, trigger_entity); } #[test] fn test_beginning_of_combat_system_phase_progression() { let mut app = App::new(); app.add_systems(Update, beginning_of_combat_system); app.add_event::<GameEvent>(); app.init_resource::<PrioritySystem>(); app.init_resource::<NextState<Phase>>(); // Set up active player let active_player = app.world.spawn((Player::default(), ActivePlayer)).id(); // Set up turn manager with Beginning of Combat phase let mut turn_manager = TurnManager::default(); turn_manager.current_phase = Phase::Combat(CombatStep::BeginningOfCombat); app.insert_resource(turn_manager); // Set up priority system to indicate all players have passed let mut priority_system = PrioritySystem::default(); priority_system.priority_given = true; priority_system.current_player_index = 0; priority_system.all_passed = true; priority_system.stack_changed = false; app.insert_resource(priority_system); // Run the system app.update(); // Check that phase advanced to Declare Attackers let next_phase = app.world.resource::<NextState<Phase>>(); assert_eq!(next_phase.0, Some(Phase::Combat(CombatStep::DeclareAttackers))); } // Additional unit tests... } }
Integration Tests
#![allow(unused)] fn main() { #[cfg(test)] mod integration_tests { use super::*; #[test] fn test_beginning_of_combat_workflow() { let mut app = App::new(); // Add all relevant systems app.add_systems(Update, ( beginning_of_combat_system, beginning_of_combat_triggers, evaluate_combat_static_abilities, process_until_next_combat_effects, clear_end_of_combat_effects, )); // Set up game state // Implementation details omitted for brevity // Run several updates to simulate entire beginning of combat step for _ in 0..5 { app.update(); } // Verify that all systems executed correctly // Implementation details omitted for brevity } // Additional integration tests... } }
UI Considerations
During the Beginning of Combat step, the user interface should:
- Clearly indicate the current phase (Beginning of Combat)
- Show which player has priority
- Highlight creatures that could potentially attack
- Display any relevant triggered abilities that are waiting to go on the stack
This can be implemented with the following system:
#![allow(unused)] fn main() { pub fn update_beginning_of_combat_ui( turn_manager: Res<TurnManager>, priority_system: Res<PrioritySystem>, creature_query: Query<(Entity, &Creature, &Controllable)>, ability_triggers: Res<AbilityTriggerQueue>, mut ui_state: ResMut<UiState>, ) { // Only run during Beginning of Combat if turn_manager.current_phase != Phase::Combat(CombatStep::BeginningOfCombat) { return; } // Update phase display ui_state.current_phase_text = "Beginning of Combat".to_string(); // Show player with priority ui_state.player_with_priority = priority_system.get_player_with_priority(); // Highlight potential attackers let active_player = turn_manager.get_active_player(); for (entity, creature, controllable) in creature_query.iter() { if controllable.controller == active_player && creature.can_attack() { ui_state.potential_attackers.insert(entity); } } // Display waiting triggers ui_state.pending_triggers = ability_triggers.queue.iter() .map(|trigger| (trigger.source, trigger.trigger.clone())) .collect(); } }
Performance Considerations
The Beginning of Combat step generally has fewer performance implications than subsequent combat steps, but we should still be mindful of:
- Efficiently processing "beginning of combat" triggers, which could be numerous
- Minimizing component queries by combining related operations
- Only performing combat-specific calculations once per beginning of combat step
Conclusion
The Beginning of Combat step, while often quickly passed through in many games, serves a crucial role in the overall combat structure. It provides the last opportunity for players to act before attackers are declared, and it's the time when many powerful combat-related triggered abilities occur. A robust implementation of this step ensures that all cards function correctly and players have appropriate opportunities to respond before the action of combat truly begins.
Declare Attackers Step
Overview
The Declare Attackers step is where the active player selects which creatures will attack and which opponents or planeswalkers they will target. In Commander, this step is particularly complex due to the multiplayer nature of the format, allowing attacks against different opponents simultaneously. This document details the implementation of the Declare Attackers step in our game engine.
Core Implementation
Phase Structure
The Declare Attackers step follows the Beginning of Combat step in the combat phase sequence:
#![allow(unused)] fn main() { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CombatStep { BeginningOfCombat, DeclareAttackers, DeclareBlockers, CombatDamage, EndOfCombat, } }
Declare Attackers System
The core system that handles the Declare Attackers step:
#![allow(unused)] fn main() { pub fn declare_attackers_system( mut commands: Commands, turn_manager: Res<TurnManager>, mut game_events: EventWriter<GameEvent>, mut next_phase: ResMut<NextState<Phase>>, mut priority_system: ResMut<PrioritySystem>, mut combat_system: ResMut<CombatSystem>, mut attack_declarations: EventReader<AttackDeclarationEvent>, ) { // Only run during Declare Attackers step if turn_manager.current_phase != Phase::Combat(CombatStep::DeclareAttackers) { return; } // If this is the first time entering the step if !priority_system.priority_given { // Emit Declare Attackers event let active_player = turn_manager.get_active_player(); game_events.send(GameEvent::DeclareAttackersStep { player: active_player, }); // Process attack requirements and restrictions commands.run_system(process_attack_requirements); // Grant priority to active player for attack declarations priority_system.grant_initial_priority(); } // Process any attack declarations for event in attack_declarations.iter() { process_attack_declaration(&mut combat_system, event, &mut game_events); } // If active player has passed priority with attack declarations finalized if priority_system.active_player_passed && combat_system.attack_declarations_finalized { // Process post-declaration triggers commands.run_system(process_attack_triggers); // Reset priority for all players to respond to attacks priority_system.reset_with_active_player_priority(); } // If all players have passed priority and the stack is empty if priority_system.all_players_passed() && priority_system.stack.is_empty() { // If at least one attacker was declared if !combat_system.attackers.is_empty() { // Proceed to Declare Blockers step next_phase.set(Phase::Combat(CombatStep::DeclareBlockers)); } else { // Skip to End of Combat if no attackers next_phase.set(Phase::Combat(CombatStep::EndOfCombat)); } priority_system.priority_given = false; } } // Helper function to process an attack declaration fn process_attack_declaration( combat_system: &mut CombatSystem, event: &AttackDeclarationEvent, game_events: &mut EventWriter<GameEvent>, ) { let AttackDeclarationEvent { attacker, defender } = event; // Validate attack declaration if let Some(reason) = validate_attack(*attacker, *defender, combat_system) { game_events.send(GameEvent::InvalidAttackDeclaration { attacker: *attacker, defender: *defender, reason, }); return; } // Record the attack in the combat system combat_system.attackers.insert(*attacker, AttackData { attacker: *attacker, defender: *defender, is_commander: false, // Will be updated by a separate system requirements: Vec::new(), restrictions: Vec::new(), }); // Emit attack declaration event game_events.send(GameEvent::AttackDeclared { attacker: *attacker, defender: *defender, }); } }
Attack Validation
Attacks must be validated according to various rules and restrictions:
#![allow(unused)] fn main() { fn validate_attack( attacker: Entity, defender: Entity, combat_system: &CombatSystem, ) -> Option<String> { // Check if creature is able to attack if let Some(restrictions) = combat_system.attack_restrictions.get(&attacker) { for restriction in restrictions { match restriction { AttackRestriction::CantAttack => { return Some("Creature cannot attack".to_string()); }, AttackRestriction::CantAttackPlayer(player) => { if *player == defender { return Some("Creature cannot attack this player".to_string()); } }, AttackRestriction::CantAttackThisTurn => { return Some("Creature cannot attack this turn".to_string()); }, // Other restrictions... } } } // Check defender-specific restrictions if let Some(restrictions) = combat_system.defender_restrictions.get(&defender) { for restriction in restrictions { match restriction { DefenderRestriction::CantBeAttacked => { return Some("This player or planeswalker cannot be attacked".to_string()); }, DefenderRestriction::CantBeAttackedBy(condition) => { if condition.matches(attacker) { return Some("This creature cannot attack this defender".to_string()); } }, // Other restrictions... } } } // All checks passed None } }
Attack Requirements
Some creatures have requirements that dictate how they must attack:
#![allow(unused)] fn main() { pub fn process_attack_requirements( mut combat_system: ResMut<CombatSystem>, creature_query: Query<(Entity, &Creature, &Controllable)>, active_player_query: Query<Entity, With<ActivePlayer>>, mut game_events: EventWriter<GameEvent>, ) { let active_player = active_player_query.single(); // Process creatures with attack requirements for (entity, creature, controllable) in creature_query.iter() { // Only check creatures controlled by active player if controllable.controller != active_player { continue; } // Check if creature has attack requirements if let Some(requirements) = combat_system.attack_requirements.get(&entity) { for requirement in requirements { match requirement { AttackRequirement::MustAttack => { // Creature must attack if able combat_system.add_required_attacker(entity); game_events.send(GameEvent::AttackRequirement { creature: entity, requirement: "Must attack if able".to_string(), }); }, AttackRequirement::MustAttackSpecificPlayer(player) => { // Creature must attack a specific player combat_system.add_required_attacker(entity); combat_system.add_required_defender(*player, entity); game_events.send(GameEvent::AttackRequirement { creature: entity, requirement: format!("Must attack player {:?} if able", player), }); }, // Other requirements... } } } } } }
Multiplayer Considerations
In Commander, the active player can attack multiple opponents in the same combat phase:
#![allow(unused)] fn main() { pub fn validate_multiplayer_attacks( combat_system: Res<CombatSystem>, turn_manager: Res<TurnManager>, player_query: Query<(Entity, &Player)>, mut game_events: EventWriter<GameEvent>, ) { let active_player = turn_manager.get_active_player(); // Group attacks by defender let mut attacks_by_defender: HashMap<Entity, Vec<Entity>> = HashMap::new(); for (attacker, attack_data) in &combat_system.attackers { attacks_by_defender.entry(attack_data.defender) .or_insert_with(Vec::new) .push(*attacker); } // Verify all defenders are opponents for (defender, attackers) in &attacks_by_defender { // Skip planeswalkers (they're handled separately) if let Ok((_, player)) = player_query.get(*defender) { // Verify defender is not the active player if *defender == active_player { game_events.send(GameEvent::InvalidAttackTarget { attacker: attackers[0], // Just report one of the attackers defender: *defender, reason: "Cannot attack yourself".to_string(), }); } } } // Log all attack declarations for game history game_events.send(GameEvent::MultiplayerAttacksDeclared { active_player, attacks_by_defender: attacks_by_defender.clone(), }); } }
Commander-Specific Implementations
Commander Attack Tracking
When a commander attacks, it needs to be specially tracked for commander damage:
#![allow(unused)] fn main() { pub fn track_commander_attacks( mut combat_system: ResMut<CombatSystem>, commander_query: Query<Entity, With<Commander>>, ) { // Find all commanders that are attacking for (attacker, attack_data) in combat_system.attackers.iter_mut() { if commander_query.contains(*attacker) { attack_data.is_commander = true; } } } }
Goad Implementation
Goad is a Commander-specific mechanic that forces creatures to attack:
#![allow(unused)] fn main() { #[derive(Component)] pub struct Goaded { pub source: Entity, pub until_end_of_turn: bool, } pub fn apply_goad_requirements( mut combat_system: ResMut<CombatSystem>, goaded_query: Query<(Entity, &Goaded, &Controllable)>, turn_manager: Res<TurnManager>, ) { let active_player = turn_manager.get_active_player(); // Find all goaded creatures controlled by the active player for (entity, goaded, controllable) in goaded_query.iter() { if controllable.controller == active_player { // Add attack requirement - must attack if able combat_system.add_attack_requirement(entity, AttackRequirement::MustAttack); // Add attack restriction - can't attack the goad source combat_system.add_attack_restriction(entity, AttackRestriction::CantAttackPlayer(goaded.source)); } } } }
Triggered Abilities
Attack Triggers
When attackers are declared, various triggered abilities might occur:
#![allow(unused)] fn main() { pub fn process_attack_triggers( turn_manager: Res<TurnManager>, combat_system: Res<CombatSystem>, mut ability_triggers: ResMut<AbilityTriggerQueue>, trigger_sources: Query<(Entity, &AbilityTrigger, &Controllable)>, ) { // Process "when this creature attacks" triggers for (attacker, _) in combat_system.attackers.iter() { if let Ok((entity, trigger, _)) = trigger_sources.get(*attacker) { if let TriggerCondition::WhenAttacks = trigger.condition { ability_triggers.queue.push_back(AbilityTriggerEvent { source: entity, trigger: trigger.clone(), targets: Vec::new(), }); } } } // Process "whenever a creature attacks" triggers for (entity, trigger, controllable) in trigger_sources.iter() { if let TriggerCondition::WheneverCreatureAttacks { controller_only } = trigger.condition { // Only consider triggers that should fire based on controller let should_trigger = if controller_only { // Check if any of the attacking creatures are controlled by this trigger's controller combat_system.attackers.iter().any(|(_, attack_data)| { // Simplified for brevity, actual implementation would check creature controllers true }) } else { // Trigger for any attacking creature !combat_system.attackers.is_empty() }; if should_trigger { ability_triggers.queue.push_back(AbilityTriggerEvent { source: entity, trigger: trigger.clone(), targets: Vec::new(), }); } } } } }
Exert Mechanic
Exert is a mechanic that gives benefits in exchange for the creature not untapping:
#![allow(unused)] fn main() { #[derive(Component)] pub struct Exert { pub duration: ExertDuration, pub effect: ExertEffect, } pub enum ExertDuration { NextUntapStep, NextUntapStepController, } pub fn handle_exert_choices( mut commands: Commands, mut exert_choices: EventReader<ExertChoiceEvent>, mut game_events: EventWriter<GameEvent>, ) { for event in exert_choices.iter() { let ExertChoiceEvent { creature, exert } = event; if *exert { // Mark the creature as exerted commands.entity(*creature).insert(Exerted { until_next_untap_step: true, }); // Apply exert effect // Implementation details omitted game_events.send(GameEvent::CreatureExerted { creature: *creature, }); } } } }
State Tracking
Once all attackers are declared, we need to update the game state:
#![allow(unused)] fn main() { pub fn update_creature_state_on_attack( mut commands: Commands, combat_system: Res<CombatSystem>, mut creature_query: Query<(Entity, &mut Creature)>, ) { // Update all attacking creatures for (attacker, attack_data) in combat_system.attackers.iter() { if let Ok((_, mut creature)) = creature_query.get_mut(*attacker) { // Mark creature as attacking creature.attacking = Some(attack_data.defender); // Add the Attacking component for faster queries commands.entity(*attacker).insert(Attacking { defender: attack_data.defender, }); // Tap the creature unless it has vigilance if !creature.has_ability(CreatureAbility::Vigilance) { commands.entity(*attacker).insert(Tapped(true)); } } } } }
Edge Cases and Special Interactions
Attack Redirection Effects
Some effects can redirect attacks to different players or planeswalkers:
#![allow(unused)] fn main() { pub fn handle_attack_redirection( mut combat_system: ResMut<CombatSystem>, redirection_effects: Query<(Entity, &AttackRedirection)>, mut game_events: EventWriter<GameEvent>, ) { // Process any attack redirection effects let redirections: Vec<(Entity, Entity, Entity)> = combat_system.attackers .iter() .filter_map(|(attacker, attack_data)| { for (effect_entity, redirection) in redirection_effects.iter() { if redirection.original_defender == attack_data.defender && redirection.applies_to(*attacker) { return Some((*attacker, attack_data.defender, redirection.new_defender)); } } None }) .collect(); // Apply redirections for (attacker, original_defender, new_defender) in redirections { if let Some(attack_data) = combat_system.attackers.get_mut(&attacker) { // Log the redirection game_events.send(GameEvent::AttackRedirected { attacker, original_defender, new_defender, }); // Update the attack target attack_data.defender = new_defender; } } } }
Attack Cost Effects
Some effects add costs to attacking:
#![allow(unused)] fn main() { pub fn handle_attack_costs( mut commands: Commands, mut combat_system: ResMut<CombatSystem>, cost_effects: Query<(Entity, &AttackCost)>, mut game_events: EventWriter<GameEvent>, mut mana_events: EventWriter<ManaPaymentEvent>, ) { // Process any attack cost effects let costs: Vec<(Entity, Entity, AttackCostType)> = combat_system.attackers .iter() .filter_map(|(attacker, attack_data)| { for (effect_entity, cost) in cost_effects.iter() { if cost.applies_to(*attacker, attack_data.defender) { return Some((*attacker, effect_entity, cost.cost_type.clone())); } } None }) .collect(); // Apply costs for (attacker, cost_source, cost_type) in costs { match cost_type { AttackCostType::Mana(cost) => { // Request mana payment mana_events.send(ManaPaymentEvent { source: attacker, reason: PaymentReason::AttackCost { creature: attacker }, cost, }); }, AttackCostType::Life(amount) => { // Implementation for life payment // Details omitted }, // Other cost types... } game_events.send(GameEvent::AttackCostApplied { attacker, cost_source, cost_description: format!("{:?}", cost_type), }); } } }
Testing Strategy
Unit Tests
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn test_attack_validation() { // Test with a creature that can attack let result = validate_attack( /* mock entities and combat system */ Entity::from_raw(1), Entity::from_raw(2), &CombatSystem::default(), ); assert_eq!(result, None, "Valid attack should return None"); // Test with a creature that can't attack let mut combat_system = CombatSystem::default(); combat_system.add_attack_restriction( Entity::from_raw(1), AttackRestriction::CantAttack, ); let result = validate_attack( Entity::from_raw(1), Entity::from_raw(2), &combat_system, ); assert!(result.is_some(), "Invalid attack should return an error message"); } #[test] fn test_commander_attack_tracking() { let mut app = App::new(); app.add_systems(Update, track_commander_attacks); // Set up a commander and a regular creature let commander = app.world.spawn((Creature::default(), Commander)).id(); let regular_creature = app.world.spawn(Creature::default()).id(); // Set up combat system with both attacking let mut combat_system = CombatSystem::default(); combat_system.attackers.insert(commander, AttackData { attacker: commander, defender: Entity::from_raw(3), is_commander: false, // Should be updated by system requirements: Vec::new(), restrictions: Vec::new(), }); combat_system.attackers.insert(regular_creature, AttackData { attacker: regular_creature, defender: Entity::from_raw(3), is_commander: false, requirements: Vec::new(), restrictions: Vec::new(), }); app.insert_resource(combat_system); // Run the system app.update(); // Check commander status let combat_system = app.world.resource::<CombatSystem>(); assert!(combat_system.attackers[&commander].is_commander, "Commander should be marked as such when attacking"); assert!(!combat_system.attackers[®ular_creature].is_commander, "Regular creature should not be marked as a commander"); } #[test] fn test_goad_mechanic() { let mut app = App::new(); app.add_systems(Update, apply_goad_requirements); // Set up test environment with a goaded creature let active_player = app.world.spawn(Player::default()).id(); let opponent = app.world.spawn(Player::default()).id(); let goaded_creature = app.world.spawn(( Creature::default(), Controllable { controller: active_player }, Goaded { source: opponent, until_end_of_turn: true }, )).id(); // Set up turn manager let mut turn_manager = TurnManager::default(); turn_manager.active_player_index = 0; turn_manager.player_order = vec![active_player, opponent]; app.insert_resource(turn_manager); // Set up combat system let combat_system = CombatSystem::default(); app.insert_resource(combat_system); // Run the system app.update(); // Check results let combat_system = app.world.resource::<CombatSystem>(); // Goaded creature should be required to attack let has_must_attack = combat_system.attack_requirements.get(&goaded_creature) .map_or(false, |reqs| reqs.iter().any(|req| matches!(req, AttackRequirement::MustAttack))); assert!(has_must_attack, "Goaded creature should have MustAttack requirement"); // Goaded creature should not be able to attack the goad source let has_cant_attack_source = combat_system.attack_restrictions.get(&goaded_creature) .map_or(false, |reqs| reqs.iter().any(|req| matches!(req, AttackRestriction::CantAttackPlayer(p) if *p == opponent))); assert!(has_cant_attack_source, "Goaded creature should not be able to attack goad source"); } // Additional unit tests... } }
Integration Tests
#![allow(unused)] fn main() { #[cfg(test)] mod integration_tests { use super::*; #[test] fn test_declare_attackers_workflow() { let mut app = App::new(); // Add all relevant systems app.add_systems(Update, ( declare_attackers_system, process_attack_requirements, track_commander_attacks, process_attack_triggers, update_creature_state_on_attack, )); // Set up game state with players and creatures // Implementation details omitted for brevity // Simulate player declaring attackers app.world.resource_mut::<Events<AttackDeclarationEvent>>().send( AttackDeclarationEvent { attacker: Entity::from_raw(1), defender: Entity::from_raw(2), } ); // Run update to process declarations app.update(); // Verify attackers are properly recorded and state is updated // Implementation details omitted for brevity } #[test] fn test_multiplayer_attack_declarations() { let mut app = App::new(); // Set up a multiplayer environment with three players let active_player = app.world.spawn(Player::default()).id(); let opponent1 = app.world.spawn(Player::default()).id(); let opponent2 = app.world.spawn(Player::default()).id(); // Set up creatures for the active player let creature1 = app.world.spawn(Creature::default()).id(); let creature2 = app.world.spawn(Creature::default()).id(); // Set up turn manager let mut turn_manager = TurnManager::default(); turn_manager.active_player_index = 0; turn_manager.player_order = vec![active_player, opponent1, opponent2]; app.insert_resource(turn_manager); // Set up combat system with attacks against different opponents let mut combat_system = CombatSystem::default(); combat_system.attackers.insert(creature1, AttackData { attacker: creature1, defender: opponent1, is_commander: false, requirements: Vec::new(), restrictions: Vec::new(), }); combat_system.attackers.insert(creature2, AttackData { attacker: creature2, defender: opponent2, is_commander: false, requirements: Vec::new(), restrictions: Vec::new(), }); app.insert_resource(combat_system); // Add validate_multiplayer_attacks system app.add_systems(Update, validate_multiplayer_attacks); app.add_event::<GameEvent>(); // Run the system app.update(); // Check for multiplayer attack event let mut found_event = false; let events = app.world.resource::<Events<GameEvent>>(); let mut reader = events.get_reader(); for event in reader.iter(events) { if let GameEvent::MultiplayerAttacksDeclared { .. } = event { found_event = true; break; } } assert!(found_event, "Multiplayer attack event should be emitted"); } // Additional integration tests... } }
UI Considerations
The UI during the Declare Attackers step needs to clearly communicate various states:
#![allow(unused)] fn main() { pub fn update_declare_attackers_ui( turn_manager: Res<TurnManager>, combat_system: Res<CombatSystem>, creature_query: Query<(Entity, &Creature, &Controllable)>, player_query: Query<Entity, With<Player>>, mut ui_state: ResMut<UiState>, ) { // Only run during Declare Attackers step if turn_manager.current_phase != Phase::Combat(CombatStep::DeclareAttackers) { return; } // Update phase display ui_state.current_phase_text = "Declare Attackers".to_string(); // Get active player let active_player = turn_manager.get_active_player(); // Highlight potential attackers ui_state.potential_attackers.clear(); for (entity, creature, controllable) in creature_query.iter() { if controllable.controller == active_player && creature.can_attack() { ui_state.potential_attackers.insert(entity); // Mark creatures that must attack if let Some(requirements) = combat_system.attack_requirements.get(&entity) { if requirements.iter().any(|req| matches!(req, AttackRequirement::MustAttack)) { ui_state.creatures_with_requirements.insert(entity, "Must attack if able".to_string()); } } } } // Highlight potential defenders ui_state.potential_defenders.clear(); for entity in player_query.iter() { if entity != active_player { ui_state.potential_defenders.insert(entity); } } // Show current attack declarations ui_state.current_attacks.clear(); for (attacker, attack_data) in combat_system.attackers.iter() { ui_state.current_attacks.insert(*attacker, attack_data.defender); } } }
Performance Considerations
-
Efficient Attack Validation: The validation of attacks should be optimized to avoid redundant checks.
-
Caching Attack Results: Once attack declarations are finalized, the results can be cached for use in subsequent steps.
-
Parallel Processing: For games with many attackers, processing attack triggers could be done in parallel.
-
Minimize Component Access: Group related queries to minimize entity access operations.
Conclusion
The Declare Attackers step is a critical part of the combat phase in Commander. A robust implementation ensures that all game rules are properly enforced, including multiplayer-specific mechanics like Goad. By handling attack declarations, restrictions, requirements, and triggers correctly, we provide the foundation for a smooth and accurate combat resolution process.
Declare Blockers Step
Overview
The Declare Blockers step is the phase of combat where defending players assign their creatures to block attacking creatures. In Commander, this step can be particularly complex due to the multiplayer nature of the format, where multiple players may be defending against attacks simultaneously. This document outlines the implementation of the Declare Blockers step in our game engine.
Core Implementation
Phase Structure
The Declare Blockers step follows the Declare Attackers step in the combat phase sequence:
#![allow(unused)] fn main() { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CombatStep { BeginningOfCombat, DeclareAttackers, DeclareBlockers, CombatDamage, EndOfCombat, } }
Declare Blockers System
The core system that handles the Declare Blockers step:
#![allow(unused)] fn main() { pub fn declare_blockers_system( mut commands: Commands, turn_manager: Res<TurnManager>, mut game_events: EventWriter<GameEvent>, mut next_phase: ResMut<NextState<Phase>>, mut priority_system: ResMut<PrioritySystem>, combat_system: Res<CombatSystem>, mut block_declarations: EventReader<BlockDeclarationEvent>, ) { // Only run during Declare Blockers step if turn_manager.current_phase != Phase::Combat(CombatStep::DeclareBlockers) { return; } // If this is the first time entering the step if !priority_system.priority_given { // Emit Declare Blockers event game_events.send(GameEvent::DeclareBlockersStep); // Process block requirements and restrictions commands.run_system(process_block_requirements); // Create turn order for block declarations priority_system.set_player_order_for_blockers(combat_system.attacked_players()); // Grant priority to first defending player priority_system.grant_initial_priority(); } // Process any block declarations for event in block_declarations.iter() { process_block_declaration(&mut commands, event, &combat_system, &mut game_events); } // If all defending players have passed priority with block declarations finalized if priority_system.all_defenders_passed && combat_system.block_declarations_finalized { // Process post-declaration triggers commands.run_system(process_block_triggers); // Reset priority for all players to respond to blocks priority_system.reset_with_active_player_priority(); } // If all players have passed priority and the stack is empty if priority_system.all_players_passed() && priority_system.stack.is_empty() { // Proceed to Combat Damage step next_phase.set(Phase::Combat(CombatStep::CombatDamage)); priority_system.priority_given = false; } } // Helper function to process a block declaration fn process_block_declaration( commands: &mut Commands, event: &BlockDeclarationEvent, combat_system: &CombatSystem, game_events: &mut EventWriter<GameEvent>, ) { let BlockDeclarationEvent { blocker, attackers } = event; // Validate block declaration if let Some(reason) = validate_block(*blocker, attackers, combat_system) { game_events.send(GameEvent::InvalidBlockDeclaration { blocker: *blocker, attackers: attackers.clone(), reason, }); return; } // Record the block in the combat system commands.entity(*blocker).insert(Blocking { blocked_attackers: attackers.clone(), }); // Emit block declaration event for attacker in attackers { game_events.send(GameEvent::BlockDeclared { blocker: *blocker, attacker: *attacker, }); } } }
Block Validation
Blocks must be validated according to various rules and restrictions:
#![allow(unused)] fn main() { fn validate_block( blocker: Entity, attackers: &[Entity], combat_system: &CombatSystem, ) -> Option<String> { // Check if creature can block at all if let Some(restrictions) = combat_system.block_restrictions.get(&blocker) { for restriction in restrictions { match restriction { BlockRestriction::CantBlock => { return Some("Creature cannot block".to_string()); }, // Other general block restrictions... } } } // Check if creature can block multiple attackers if attackers.len() > 1 { let can_block_multiple = check_can_block_multiple(blocker, combat_system); if !can_block_multiple { return Some("Creature cannot block multiple attackers".to_string()); } } // Check attacker-specific restrictions for attacker in attackers { // Check if this attacker can be blocked by this blocker if let Some(restrictions) = combat_system.attacker_restrictions.get(attacker) { for restriction in restrictions { match restriction { AttackerRestriction::CantBeBlocked => { return Some(format!("Attacker {} cannot be blocked", attacker.index())); }, AttackerRestriction::CantBeBlockedBy(condition) => { if condition.matches(blocker) { return Some(format!("Attacker {} cannot be blocked by this creature", attacker.index())); } }, // Other attacker-specific restrictions... } } } } // Check special blocking requirements if let Some(requirements) = combat_system.attacker_block_requirements.get(&attackers[0]) { for requirement in requirements { match requirement { BlockRequirement::MustBeBlockedByAtLeast(count) => { if combat_system.get_blockers_for_attacker(attackers[0]).len() + 1 < *count { // This single blocker is not enough to satisfy the requirement // Note: In a real implementation, this would need to check if the requirement // could be satisfied with other declared blocks return Some(format!("Attacker requires at least {} blockers", count)); } }, // Other block requirements... } } } // All checks passed None } // Helper function to check if a creature can block multiple attackers fn check_can_block_multiple( blocker: Entity, combat_system: &CombatSystem, ) -> bool { // Check if creature has a special ability that allows blocking multiple attackers if let Some(special_abilities) = combat_system.creature_special_abilities.get(&blocker) { if special_abilities.contains(&SpecialAbility::CanBlockAdditionalCreature(1)) { return true; } if special_abilities.contains(&SpecialAbility::CanBlockAnyNumber) { return true; } } // By default, creatures can only block one attacker false } }
Block Requirements
Some effects in the game can force creatures to block:
#![allow(unused)] fn main() { pub fn process_block_requirements( combat_system: Res<CombatSystem>, mut game_events: EventWriter<GameEvent>, creature_query: Query<(Entity, &Creature, &Controllable)>, player_query: Query<(Entity, &Player)>, ) { // Identify all players who are being attacked let attacked_players: HashSet<Entity> = combat_system.attackers .iter() .filter_map(|(_, attack_data)| { if player_query.contains(attack_data.defender) { Some(attack_data.defender) } else { None } }) .collect(); // Process creatures with block requirements for (entity, creature, controllable) in creature_query.iter() { // Only check creatures controlled by players being attacked if !attacked_players.contains(&controllable.controller) { continue; } // Check if creature has block requirements if let Some(requirements) = combat_system.block_requirements.get(&entity) { for requirement in requirements { match requirement { BlockRequirement::MustBlock => { // Creature must block if able game_events.send(GameEvent::BlockRequirement { creature: entity, requirement: "Must block if able".to_string(), }); }, BlockRequirement::MustBlockAttacker(attacker) => { // Creature must block a specific attacker if combat_system.attackers.contains_key(attacker) { game_events.send(GameEvent::BlockRequirement { creature: entity, requirement: format!("Must block attacker {:?} if able", attacker), }); } }, // Other requirements... } } } } } }
Evasion Abilities
Evasion abilities like Flying, Menace, etc. are essential to the blocking rules:
#![allow(unused)] fn main() { pub fn apply_evasion_restrictions( mut combat_system: ResMut<CombatSystem>, creature_query: Query<(Entity, &Creature)>, ) { // Process flying for (entity, creature) in creature_query.iter() { if creature.has_ability(CreatureAbility::Flying) { // Flying creatures can only be blocked by creatures with flying or reach combat_system.add_attacker_restriction( entity, AttackerRestriction::CantBeBlockedBy(BlockerCondition::NotFlyingOrReach), ); } if creature.has_ability(CreatureAbility::Menace) { // Menace creatures must be blocked by at least two creatures combat_system.add_attacker_block_requirement( entity, BlockRequirement::MustBeBlockedByAtLeast(2), ); } if creature.has_ability(CreatureAbility::Fear) { // Fear creatures can only be blocked by black or artifact creatures combat_system.add_attacker_restriction( entity, AttackerRestriction::CantBeBlockedBy(BlockerCondition::NotBlackOrArtifact), ); } // Implement other evasion abilities } } }
Multiplayer Considerations
In multiplayer Commander games, multiple players might need to declare blockers:
#![allow(unused)] fn main() { pub fn handle_multiplayer_blocks( combat_system: Res<CombatSystem>, turn_manager: Res<TurnManager>, player_query: Query<(Entity, &Player)>, mut priority_system: ResMut<PrioritySystem>, ) { // Group attackers by defending player let mut attackers_by_defender: HashMap<Entity, Vec<Entity>> = HashMap::new(); for (attacker, attack_data) in &combat_system.attackers { if player_query.contains(attack_data.defender) { attackers_by_defender.entry(attack_data.defender) .or_insert_with(Vec::new) .push(*attacker); } } // Set up priority for each defending player in turn order let mut defender_order = Vec::new(); for player_idx in 0..turn_manager.player_order.len() { let player_entity = turn_manager.player_order[player_idx]; if attackers_by_defender.contains_key(&player_entity) { defender_order.push(player_entity); } } // Update priority system with defender order priority_system.defender_order = defender_order; } }
Special Blocking Rules
Multiple Blockers
When a single attacker is blocked by multiple creatures:
#![allow(unused)] fn main() { pub fn handle_multiple_blockers( mut combat_system: ResMut<CombatSystem>, blocking_query: Query<(Entity, &Blocking)>, mut game_events: EventWriter<GameEvent>, ) { // Build a map of attackers to their blockers let mut blockers_by_attacker: HashMap<Entity, Vec<Entity>> = HashMap::new(); for (blocker, blocking) in blocking_query.iter() { for attacker in &blocking.blocked_attackers { blockers_by_attacker.entry(*attacker) .or_insert_with(Vec::new) .push(blocker); } } // Process attackers with multiple blockers for (attacker, blockers) in blockers_by_attacker.iter() { if blockers.len() > 1 { // Attacker's controller declares damage assignment order game_events.send(GameEvent::DamageAssignmentOrderNeeded { attacker: *attacker, blockers: blockers.clone(), }); } } } }
Triggered Abilities
When blockers are declared, various triggered abilities might occur:
#![allow(unused)] fn main() { pub fn process_block_triggers( combat_system: Res<CombatSystem>, blocking_query: Query<(Entity, &Blocking)>, mut ability_triggers: ResMut<AbilityTriggerQueue>, trigger_sources: Query<(Entity, &AbilityTrigger)>, ) { // Create attacker to blocker map let mut blockers_by_attacker: HashMap<Entity, Vec<Entity>> = HashMap::new(); for (blocker, blocking) in blocking_query.iter() { for attacker in &blocking.blocked_attackers { blockers_by_attacker.entry(*attacker) .or_insert_with(Vec::new) .push(blocker); } } // Process "when this creature blocks" triggers for (blocker, blocking) in blocking_query.iter() { if let Ok((entity, trigger)) = trigger_sources.get(blocker) { if let TriggerCondition::WhenBlocks = trigger.condition { for attacker in &blocking.blocked_attackers { ability_triggers.queue.push_back(AbilityTriggerEvent { source: entity, trigger: trigger.clone(), targets: vec![*attacker], // The attacker is the target }); } } } } // Process "when this creature becomes blocked" triggers for (attacker, blockers) in blockers_by_attacker.iter() { if let Ok((entity, trigger)) = trigger_sources.get(*attacker) { if let TriggerCondition::WhenBecomesBlocked = trigger.condition { ability_triggers.queue.push_back(AbilityTriggerEvent { source: entity, trigger: trigger.clone(), targets: blockers.clone(), // All blockers are targets }); } } } // Process "whenever a creature blocks" triggers for (entity, trigger) in trigger_sources.iter() { if let TriggerCondition::WheneverCreatureBlocks { controller_only } = trigger.condition { let should_trigger = if controller_only { // Only trigger for creatures controlled by the same player // Implementation details omitted for brevity true } else { // Trigger for any blocking creature !blocking_query.is_empty() }; if should_trigger { ability_triggers.queue.push_back(AbilityTriggerEvent { source: entity, trigger: trigger.clone(), targets: Vec::new(), // No specific targets }); } } } } }
State Tracking
Once all blockers are declared, we need to update the game state:
#![allow(unused)] fn main() { pub fn update_creature_state_on_block( mut commands: Commands, blocking_query: Query<(Entity, &Blocking, &mut Creature)>, ) { // Update all blocking creatures for (entity, blocking, mut creature) in blocking_query.iter_mut() { // Mark creature as blocking creature.blocking = blocking.blocked_attackers.clone(); // Tap the creature if it has vigilance for blocking // Note: This is not a standard MTG rule but could be a house rule or special card effect if creature.has_ability(CreatureAbility::VigilanceForBlocking) { commands.entity(entity).insert(Tapped(true)); } } } }
Edge Cases and Special Interactions
Banding
Banding is a complex ability that affects blocks:
#![allow(unused)] fn main() { pub fn handle_banding( mut combat_system: ResMut<CombatSystem>, creature_query: Query<(Entity, &Creature)>, blocking_query: Query<(Entity, &Blocking)>, ) { // Find all creatures with banding let banding_creatures: HashSet<Entity> = creature_query .iter() .filter_map(|(entity, creature)| { if creature.has_ability(CreatureAbility::Banding) { Some(entity) } else { None } }) .collect(); // Process attackers blocked by banding creatures let mut defenders_control_damage_assignment: HashSet<Entity> = HashSet::new(); for (blocker, blocking) in blocking_query.iter() { if banding_creatures.contains(&blocker) { // If a banding creature is blocking, the defender controls damage assignment for attacker in &blocking.blocked_attackers { defenders_control_damage_assignment.insert(*attacker); } } } // Update combat system for attacker in defenders_control_damage_assignment { combat_system.add_attacker_flag(attacker, AttackerFlag::DefenderControlsDamageAssignment); } } }
Protection
Protection affects blocking in various ways:
#![allow(unused)] fn main() { pub fn apply_protection_block_restrictions( mut combat_system: ResMut<CombatSystem>, creature_query: Query<(Entity, &Creature, &Protection)>, ) { for (entity, _, protection) in creature_query.iter() { match protection.from { ProtectionFrom::Color(color) => { // Creature with protection from a color can't be blocked by creatures of that color combat_system.add_attacker_restriction( entity, AttackerRestriction::CantBeBlockedBy(BlockerCondition::HasColor(color)), ); // Creature with protection can't block creatures of that color combat_system.add_block_restriction( entity, BlockRestriction::CantBlockCreature(AttackerCondition::HasColor(color)), ); }, ProtectionFrom::Type(card_type) => { // Similar restrictions for protection from a type // Implementation details omitted for brevity }, // Other protection types... } } } }
Testing Strategy
Unit Tests
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn test_block_validation() { // Test with a creature that can block let result = validate_block( /* mock entities and combat system */ Entity::from_raw(1), &[Entity::from_raw(2)], &CombatSystem::default(), ); assert_eq!(result, None, "Valid block should return None"); // Test with a creature that can't block let mut combat_system = CombatSystem::default(); combat_system.add_block_restriction( Entity::from_raw(1), BlockRestriction::CantBlock, ); let result = validate_block( Entity::from_raw(1), &[Entity::from_raw(2)], &combat_system, ); assert!(result.is_some(), "Invalid block should return an error message"); } #[test] fn test_flying_restriction() { let mut app = App::new(); app.add_systems(Update, apply_evasion_restrictions); // Create a flying creature let flying_creature = app.world.spawn(( Creature { abilities: vec![CreatureAbility::Flying], ..Default::default() }, )).id(); // Set up combat system let combat_system = CombatSystem::default(); app.insert_resource(combat_system); // Run the system app.update(); // Check if flying restriction was applied let combat_system = app.world.resource::<CombatSystem>(); let has_flying_restriction = combat_system.attacker_restrictions.get(&flying_creature) .map_or(false, |reqs| reqs.iter().any(|req| matches!(req, AttackerRestriction::CantBeBlockedBy(BlockerCondition::NotFlyingOrReach)))); assert!(has_flying_restriction, "Flying creature should have CantBeBlockedBy restriction"); } #[test] fn test_multiple_blockers() { let mut app = App::new(); app.add_systems(Update, handle_multiple_blockers); app.add_event::<GameEvent>(); // Set up an attacker and multiple blockers let attacker = app.world.spawn(Creature::default()).id(); let blocker1 = app.world.spawn(( Creature::default(), Blocking { blocked_attackers: vec![attacker] }, )).id(); let blocker2 = app.world.spawn(( Creature::default(), Blocking { blocked_attackers: vec![attacker] }, )).id(); // Set up combat system let combat_system = CombatSystem::default(); app.insert_resource(combat_system); // Run the system app.update(); // Check for damage assignment order event let mut event_reader = app.world.resource_mut::<Events<GameEvent>>(); let mut found_event = false; for event in event_reader.iter() { if let GameEvent::DamageAssignmentOrderNeeded { attacker: a, blockers } = event { if *a == attacker && blockers.contains(&blocker1) && blockers.contains(&blocker2) { found_event = true; break; } } } assert!(found_event, "Damage assignment order event should be emitted for multiple blockers"); } // Additional unit tests... } }
Integration Tests
#![allow(unused)] fn main() { #[cfg(test)] mod integration_tests { use super::*; #[test] fn test_declare_blockers_workflow() { let mut app = App::new(); // Add all relevant systems app.add_systems(Update, ( declare_blockers_system, process_block_requirements, apply_evasion_restrictions, handle_multiple_blockers, process_block_triggers, update_creature_state_on_block, )); // Set up game state with attackers and defenders // Implementation details omitted for brevity // Simulate player declaring blockers app.world.resource_mut::<Events<BlockDeclarationEvent>>().send( BlockDeclarationEvent { blocker: Entity::from_raw(1), attackers: vec![Entity::from_raw(2)], } ); // Run update to process declarations app.update(); // Verify blockers are properly recorded and state is updated // Implementation details omitted for brevity } #[test] fn test_evasion_abilities_interaction() { let mut app = App::new(); // Set up attacker with flying let flying_attacker = app.world.spawn(( Creature { abilities: vec![CreatureAbility::Flying], ..Default::default() }, )).id(); // Set up potential blockers: one with flying, one with reach, one with neither let flying_blocker = app.world.spawn(( Creature { abilities: vec![CreatureAbility::Flying], ..Default::default() }, )).id(); let reach_blocker = app.world.spawn(( Creature { abilities: vec![CreatureAbility::Reach], ..Default::default() }, )).id(); let normal_blocker = app.world.spawn(Creature::default()).id(); // Set up combat system with the attacker let mut combat_system = CombatSystem::default(); combat_system.attackers.insert(flying_attacker, AttackData { attacker: flying_attacker, defender: Entity::from_raw(10), // Some player is_commander: false, requirements: Vec::new(), restrictions: Vec::new(), }); app.insert_resource(combat_system); // Add relevant systems app.add_systems(Update, apply_evasion_restrictions); // Run the system app.update(); // Try to declare blocks with each blocker let can_flying_block = validate_block(flying_blocker, &[flying_attacker], &app.world.resource::<CombatSystem>()); let can_reach_block = validate_block(reach_blocker, &[flying_attacker], &app.world.resource::<CombatSystem>()); let can_normal_block = validate_block(normal_blocker, &[flying_attacker], &app.world.resource::<CombatSystem>()); // Verify results assert_eq!(can_flying_block, None, "Flying creature should be able to block flying attacker"); assert_eq!(can_reach_block, None, "Reach creature should be able to block flying attacker"); assert!(can_normal_block.is_some(), "Normal creature should not be able to block flying attacker"); } // Additional integration tests... } }
UI Considerations
The UI during the Declare Blockers step needs to clearly communicate various states:
#![allow(unused)] fn main() { pub fn update_declare_blockers_ui( turn_manager: Res<TurnManager>, combat_system: Res<CombatSystem>, creature_query: Query<(Entity, &Creature, &Controllable)>, attacking_query: Query<Entity, With<Attacking>>, blocking_query: Query<(Entity, &Blocking)>, mut ui_state: ResMut<UiState>, ) { // Only run during Declare Blockers step if turn_manager.current_phase != Phase::Combat(CombatStep::DeclareBlockers) { return; } // Update phase display ui_state.current_phase_text = "Declare Blockers".to_string(); // Get active player and current player with priority let active_player = turn_manager.get_active_player(); let current_player = turn_manager.get_current_player(); // Highlight attackers ui_state.attackers.clear(); for attacker in attacking_query.iter() { ui_state.attackers.insert(attacker); } // Highlight potential blockers for current player ui_state.potential_blockers.clear(); for (entity, creature, controllable) in creature_query.iter() { if controllable.controller == current_player && creature.can_block() { ui_state.potential_blockers.insert(entity); // Mark creatures that must block if let Some(requirements) = combat_system.block_requirements.get(&entity) { if requirements.iter().any(|req| matches!(req, BlockRequirement::MustBlock)) { ui_state.creatures_with_requirements.insert(entity, "Must block if able".to_string()); } } } } // Show current block declarations ui_state.current_blocks.clear(); for (blocker, blocking) in blocking_query.iter() { ui_state.current_blocks.insert(blocker, blocking.blocked_attackers.clone()); } // Highlight legal blocks ui_state.legal_blocks.clear(); for attacker in attacking_query.iter() { let legal_blockers = creature_query .iter() .filter_map(|(entity, creature, controllable)| { if controllable.controller == current_player && validate_block(entity, &[attacker], &combat_system).is_none() { Some(entity) } else { None } }) .collect::<Vec<_>>(); ui_state.legal_blocks.insert(attacker, legal_blockers); } } }
Performance Considerations
-
Efficient Block Validation: The validation of blocks should be optimized to avoid redundant checks.
-
Caching Block Results: Once block declarations are finalized, the results can be cached for use in subsequent steps.
-
Minimize Entity Queries: Group related queries to minimize entity access operations.
-
Parallel Processing: For games with many blockers, processing block triggers could be done in parallel.
Conclusion
The Declare Blockers step is a critical part of the combat phase in Commander, particularly in multiplayer games where multiple players may be defending simultaneously. A robust implementation ensures that all game rules are properly enforced, including evasion abilities, protection effects, and multi-blocker scenarios. By handling block declarations, restrictions, requirements, and triggers correctly, we provide the foundation for accurate combat resolution in the following steps.
First Strike Damage
Overview
The First Strike Damage step is a conditional sub-step within the Combat Damage step that occurs when at least one creature with first strike or double strike is involved in combat. During this step, only creatures with first strike or double strike deal combat damage, while other creatures wait until the regular Combat Damage step.
This document outlines the implementation details, edge cases, and testing strategies for the First Strike Damage step in our Commander engine.
Core Concepts
First Strike Damage Flow
The First Strike Damage step follows this general flow:
- Check if any attacking or blocking creatures have first strike or double strike
- If yes, create a dedicated First Strike Damage step before the regular Combat Damage step
- Only creatures with first strike or double strike assign and deal damage in this step
- State-based actions are checked
- Triggers from first strike damage are put on the stack
- Priority passes to players in turn order
- Once all players pass priority and the stack is empty, the game proceeds to the regular Combat Damage step
First Strike vs. Double Strike
The key difference between first strike and double strike:
- Creatures with first strike deal damage only during the First Strike Damage step
- Creatures with double strike deal damage in both the First Strike Damage step and the regular Combat Damage step
Implementation Design
Data Structures
#![allow(unused)] fn main() { // Components for first strike and double strike abilities #[derive(Component)] struct FirstStrike; #[derive(Component)] struct DoubleStrike; // System resource for tracking the first strike step struct FirstStrikeDamageSystem { triggers_processed: bool, } }
First Strike Checking System
#![allow(unused)] fn main() { fn check_for_first_strike_step( mut turn_manager: ResMut<TurnManager>, first_strike_query: Query<Entity, Or<(With<FirstStrike>, With<DoubleStrike>)>>, combat_participants: Query<Entity, Or<(With<Attacking>, With<Blocking>)>> ) { // Only check when transitioning from Declare Blockers to Combat Damage if !matches!(turn_manager.current_phase, Phase::Combat(CombatStep::DeclareBlockers)) { return; } // Check if any creature in combat has first strike or double strike let has_first_strike = combat_participants.iter().any(|entity| { first_strike_query.contains(entity) }); // If there are creatures with first strike, we'll use the FirstStrike step if has_first_strike { turn_manager.next_phase = Some(Phase::Combat(CombatStep::FirstStrike)); } else { turn_manager.next_phase = Some(Phase::Combat(CombatStep::CombatDamage)); } } }
First Strike Damage System
#![allow(unused)] fn main() { fn first_strike_damage_system( mut commands: Commands, mut combat_system: ResMut<CombatSystem>, turn_manager: Res<TurnManager>, creature_query: Query<(Entity, &Creature, &Health, Option<&FirstStrike>, Option<&DoubleStrike>)>, attacker_query: Query<(Entity, &Attacking)>, blocker_query: Query<(Entity, &Blocking)>, player_query: Query<(Entity, &Player, &Health)>, mut commander_damage: Query<&mut CommanderDamage>, // Other system parameters ) { // Only run during first strike damage step if !matches!(turn_manager.current_phase, Phase::Combat(CombatStep::FirstStrike)) { return; } // Find creatures with first strike or double strike that are in combat let first_strike_creatures = creature_query .iter() .filter(|(entity, _, _, first_strike, double_strike)| { (first_strike.is_some() || double_strike.is_some()) && (attacker_query.contains(*entity) || blocker_query.contains(*entity)) }) .collect::<Vec<_>>(); // Assign and deal damage for (entity, creature, _, _, _) in first_strike_creatures { // Determine damage amount and recipients let power = creature.power; if let Ok((_, attacking)) = attacker_query.get(entity) { // Attacking creature deals damage assign_attacker_damage( entity, power, attacking.defending, &blocker_query, &mut combat_system ); } else if let Ok((_, blocking)) = blocker_query.get(entity) { // Blocking creature deals damage assign_blocker_damage( entity, power, &blocking.blocked_attackers, &mut combat_system ); } } // Deal all assigned damage simultaneously process_damage_assignments(&mut commands, &combat_system, &mut commander_damage); // Check for state-based actions check_state_based_actions(&mut commands, &player_query, &creature_query); // Record that first strike damage has been processed combat_system.first_strike_round_completed = true; } }
Special Cases and Edge Scenarios
Gaining/Losing First Strike During Combat
Creatures might gain or lose first strike abilities during combat:
#![allow(unused)] fn main() { fn handle_first_strike_changes( mut first_strike_system: ResMut<FirstStrikeDamageSystem>, turn_manager: Res<TurnManager>, added_first_strike: Query<Entity, Added<FirstStrike>>, removed_first_strike: Query<Entity, Without<FirstStrike>>, combat_participants: Query<Entity, Or<(With<Attacking>, With<Blocking>)>>, was_in_combat: Local<HashSet<Entity>> ) { // Check if any creature in combat gained or lost first strike let gained_first_strike = added_first_strike.iter() .filter(|&entity| combat_participants.contains(entity)) .count() > 0; let lost_first_strike = removed_first_strike.iter() .filter(|&entity| was_in_combat.contains(&entity)) .count() > 0; // If changes occurred during declare blockers step, we might need to adjust // whether a first strike step will occur if matches!(turn_manager.current_phase, Phase::Combat(CombatStep::DeclareBlockers)) { if gained_first_strike { first_strike_system.needed = true; } } // Update our tracking of combat participants was_in_combat.clear(); for entity in combat_participants.iter() { was_in_combat.insert(entity); } } }
Removing Creatures During First Strike
If a creature is destroyed by first strike damage, it doesn't deal regular combat damage:
#![allow(unused)] fn main() { fn track_destroyed_by_first_strike( combat_system: Res<CombatSystem>, mut destroyed_entities: ResMut<DestroyedEntities>, damage_events: Query<&DamageEvent>, creature_query: Query<&Health> ) { // Only track during first strike damage if !combat_system.first_strike_round_completed { return; } // Find creatures that received lethal damage during first strike for damage_event in damage_events.iter() { if let Ok(health) = creature_query.get(damage_event.target) { if health.current <= 0 { destroyed_entities.insert(damage_event.target); } } } } }
Fast Effect Windows
Players get priority after first strike damage, allowing them to cast spells before regular damage:
#![allow(unused)] fn main() { fn handle_first_strike_priority( mut turn_manager: ResMut<TurnManager>, stack: Res<Stack>, combat_system: Res<CombatSystem> ) { // Only handle during first strike step if !matches!(turn_manager.current_phase, Phase::Combat(CombatStep::FirstStrike)) { return; } // If first strike damage has been dealt and the stack is empty, // we can proceed to regular combat damage if combat_system.first_strike_round_completed && stack.is_empty() { turn_manager.advance_phase(); } } }
Testing Strategy
Unit Tests
#![allow(unused)] fn main() { #[test] fn test_first_strike_damage() { // Set up test world let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, first_strike_damage_system); // Create attacker with first strike let attacker = app.world.spawn(( Creature { power: 3, toughness: 3 }, Attacking { defending: player_entity }, FirstStrike, )).id(); // Create blocker without first strike let blocker = app.world.spawn(( Creature { power: 2, toughness: 2 }, Blocking { blocked_attackers: vec![attacker] }, Health { current: 2, maximum: 2 }, )).id(); // Process first strike damage app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::FirstStrike); app.update(); // Verify blocker received damage and was destroyed let health = app.world.get::<Health>(blocker).unwrap(); assert_eq!(health.current, 0); // Received 3 damage from first strike attacker // Verify no damage was dealt to attacker yet (since blocker doesn't have first strike) let attacker_health = app.world.get::<Health>(attacker).unwrap(); assert_eq!(attacker_health.current, attacker_health.maximum); } #[test] fn test_double_strike_damage() { // Test that double strike creatures deal damage in both steps // ... } #[test] fn test_first_strike_detection() { // Test that the system correctly detects when a first strike step is needed // ... } }
Integration Tests
#![allow(unused)] fn main() { #[test] fn test_first_strike_vs_regular_damage() { // Test complete sequence with both first strike and regular damage // ... } #[test] fn test_gaining_first_strike_mid_combat() { // Test gaining first strike during combat // ... } #[test] fn test_losing_first_strike_mid_combat() { // Test losing first strike during combat // ... } }
Edge Case Tests
#![allow(unused)] fn main() { #[test] fn test_first_strike_with_triggered_abilities() { // Test interaction between first strike damage and triggered abilities // ... } #[test] fn test_first_strike_with_damage_prevention() { // Test first strike damage with damage prevention effects // ... } #[test] fn test_first_strike_with_damage_redirection() { // Test first strike damage with redirection effects // ... } }
Performance Considerations
-
Conditional Step Creation: Only create the First Strike Damage step when needed.
-
Efficient Entity Filtering: Optimize filtering of entities with first strike/double strike.
-
Damage Assignment Optimization: Reuse damage assignment logic between first strike and regular damage steps.
-
State Tracking: Efficiently track state between first strike and regular damage steps.
Conclusion
The First Strike Damage step is a specialized combat sub-step that adds strategic depth to the combat system. By correctly implementing first strike and double strike mechanics, we ensure that these powerful combat abilities function as expected according to the Magic: The Gathering rules. The implementation must handle all edge cases, including mid-combat changes to first strike status, while maintaining good performance and seamless integration with the rest of the combat system.
Combat Damage
Overview
The Combat Damage step is the critical phase of combat where damage is assigned and dealt between attacking and blocking creatures, as well as to players and planeswalkers. In Commander, this step has additional significance due to the presence of Commander damage tracking and the multiplayer nature of the format.
This document outlines the implementation details, edge cases, and testing strategies for the Combat Damage step in our Commander engine.
Core Concepts
Combat Damage Flow
The Combat Damage step follows this general flow:
- First, the active player assigns combat damage from their attacking creatures
- Then, each defending player assigns combat damage from their blocking creatures
- All damage is dealt simultaneously
- State-based actions are checked, including:
- Creatures with lethal damage are destroyed
- Players with 0 or less life lose the game
- Players with 21+ commander damage from a single commander lose the game
- Combat damage triggers are placed on the stack
- Priority passes to players in turn order
First Strike and Double Strike
Combat damage may occur in two sub-steps when creatures with first strike or double strike are involved:
- First Strike Combat Damage - Damage from creatures with first strike or double strike
- Regular Combat Damage - Damage from all other creatures and second instance of damage from double strike creatures
Implementation Design
Data Structures
#![allow(unused)] fn main() { // Represents the damage assignment for a specific attacker or blocker struct DamageAssignment { source: Entity, // The entity dealing damage target: Entity, // The entity receiving damage amount: u32, // Amount of damage is_commander_damage: bool, // Whether this is commander damage is_combat_damage: bool, // Always true for combat damage step } // Component for tracking assigned damage #[derive(Component)] struct AssignedDamage { assignments: Vec<DamageAssignment>, } // System resource for managing the combat damage step struct CombatDamageSystem { first_strike_round_completed: bool, damage_assignments: HashMap<Entity, Vec<DamageAssignment>>, } }
Combat Damage System
#![allow(unused)] fn main() { fn combat_damage_system( mut commands: Commands, mut combat_system: ResMut<CombatSystem>, turn_manager: Res<TurnManager>, creature_query: Query<(Entity, &Creature, &Health, Option<&FirstStrike>, Option<&DoubleStrike>)>, attacker_query: Query<(Entity, &Attacking)>, blocker_query: Query<(Entity, &Blocking)>, player_query: Query<(Entity, &Player, &Health)>, mut commander_damage: Query<&mut CommanderDamage>, // Other system parameters ) { // Check if we need to process first strike damage let current_step = match turn_manager.current_phase { Phase::Combat(CombatStep::FirstStrike) => CombatStep::FirstStrike, Phase::Combat(CombatStep::CombatDamage) => CombatStep::CombatDamage, _ => return, // Not in a combat damage step }; // Determine which creatures deal damage in this step let (first_strike_creatures, regular_creatures) = creature_query .iter() .partition(|(_, _, _, first_strike, double_strike)| first_strike.is_some() || double_strike.is_some() ); // Process creatures that deal damage in this step let applicable_creatures = match current_step { CombatStep::FirstStrike => &first_strike_creatures, CombatStep::CombatDamage => { // In regular combat damage, double strike creatures deal damage again, // and creatures without first/double strike deal damage for the first time let mut creatures = Vec::new(); creatures.extend(regular_creatures); creatures.extend( first_strike_creatures .iter() .filter(|(_, _, _, _, double_strike)| double_strike.is_some()) ); &creatures } _ => return, }; // Assign and deal damage for (entity, creature, health, _, _) in applicable_creatures { // Determine damage amount and recipients let power = creature.power; if let Ok((_, attacking)) = attacker_query.get(*entity) { // Attacking creature deals damage assign_attacker_damage( *entity, power, attacking.defending, &blocker_query, &mut combat_system ); } else if let Ok((_, blocking)) = blocker_query.get(*entity) { // Blocking creature deals damage assign_blocker_damage( *entity, power, &blocking.blocked_attackers, &mut combat_system ); } } // Deal all assigned damage simultaneously process_damage_assignments(&mut commands, &combat_system, &mut commander_damage); // Check for state-based actions check_state_based_actions(&mut commands, &player_query, &creature_query); // If this was the first strike step, we'll continue to regular damage // If this was regular combat damage, we'll proceed to end of combat if current_step == CombatStep::FirstStrike { combat_system.first_strike_round_completed = true; } else { combat_system.first_strike_round_completed = false; // Clean up damage assignments combat_system.damage_assignments.clear(); } } }
Damage Assignment Functions
#![allow(unused)] fn main() { fn assign_attacker_damage( attacker: Entity, power: u32, defending: Entity, blocker_query: &Query<(Entity, &Blocking)>, combat_system: &mut ResMut<CombatSystem> ) { // Find blockers for this attacker let blockers = blocker_query .iter() .filter(|(_, blocking)| blocking.blocked_attackers.contains(&attacker)) .map(|(entity, _)| entity) .collect::<Vec<_>>(); if blockers.is_empty() { // Unblocked attacker - deal damage to player or planeswalker combat_system.damage_assignments.entry(attacker).or_default().push( DamageAssignment { source: attacker, target: defending, amount: power, is_commander_damage: true, // Check if attacker is a commander is_combat_damage: true, } ); } else { // Attacker is blocked - distribute damage among blockers // (In a real implementation, this would handle player choices for damage assignment) // Simplified version: divide damage evenly let damage_per_blocker = power / blockers.len() as u32; let remainder = power % blockers.len() as u32; for (i, blocker) in blockers.iter().enumerate() { let damage = damage_per_blocker + if i < remainder as usize { 1 } else { 0 }; combat_system.damage_assignments.entry(attacker).or_default().push( DamageAssignment { source: attacker, target: *blocker, amount: damage, is_commander_damage: false, // Commander damage only applies to players is_combat_damage: true, } ); } } } fn assign_blocker_damage( blocker: Entity, power: u32, blocked_attackers: &[Entity], combat_system: &mut ResMut<CombatSystem> ) { // Simplified version: divide damage evenly among attackers // (In a real implementation, this would handle player choices for damage assignment) let damage_per_attacker = power / blocked_attackers.len() as u32; let remainder = power % blocked_attackers.len() as u32; for (i, attacker) in blocked_attackers.iter().enumerate() { let damage = damage_per_attacker + if i < remainder as usize { 1 } else { 0 }; combat_system.damage_assignments.entry(blocker).or_default().push( DamageAssignment { source: blocker, target: *attacker, amount: damage, is_commander_damage: false, is_combat_damage: true, } ); } } fn process_damage_assignments( commands: &mut Commands, combat_system: &CombatSystem, commander_damage: &mut Query<&mut CommanderDamage> ) { // Process all damage assignments for (source, assignments) in &combat_system.damage_assignments { for assignment in assignments { // Deal damage to target commands.entity(assignment.target).insert( DamageReceived { amount: assignment.amount, source: assignment.source, is_combat_damage: true, } ); // If this is commander damage, update commander damage tracker if assignment.is_commander_damage { if let Ok(mut cmd_damage) = commander_damage.get_mut(assignment.target) { cmd_damage.add_damage(assignment.source, assignment.amount); } } // Add damage event for triggers commands.spawn(DamageEvent { source: assignment.source, target: assignment.target, amount: assignment.amount, is_combat_damage: true, }); } } } }
Special Cases and Edge Scenarios
Trample
When an attacking creature with trample is blocked, excess damage is dealt to the defending player:
#![allow(unused)] fn main() { fn assign_attacker_damage_with_trample( attacker: Entity, power: u32, defending: Entity, blocker_query: &Query<(Entity, &Blocking, &Health)>, trample_query: &Query<&Trample>, combat_system: &mut ResMut<CombatSystem> ) { // Find blockers for this attacker let blockers = blocker_query .iter() .filter(|(_, blocking, _)| blocking.blocked_attackers.contains(&attacker)) .collect::<Vec<_>>(); if blockers.is_empty() { // Handle unblocked attacker as normal // ... } else if trample_query.get(attacker).is_ok() { // Attacker has trample - assign lethal damage to each blocker let mut remaining_damage = power; for (blocker, _, health) in &blockers { let lethal_damage = health.current.min(remaining_damage); remaining_damage -= lethal_damage; // Assign lethal damage to blocker combat_system.damage_assignments.entry(attacker).or_default().push( DamageAssignment { source: attacker, target: *blocker, amount: lethal_damage, is_commander_damage: false, is_combat_damage: true, } ); } // Assign remaining damage to player if remaining_damage > 0 { combat_system.damage_assignments.entry(attacker).or_default().push( DamageAssignment { source: attacker, target: defending, amount: remaining_damage, is_commander_damage: true, // Check if attacker is a commander is_combat_damage: true, } ); } } else { // Handle normal blocked creature (no trample) // ... } } }
Deathtouch
Creatures with deathtouch need to assign only 1 damage to be considered lethal:
#![allow(unused)] fn main() { fn is_lethal_damage( amount: u32, source: Entity, deathtouch_query: &Query<&Deathtouch> ) -> bool { if amount > 0 && deathtouch_query.get(source).is_ok() { return true; } amount > 0 } }
Damage Prevention and Replacement
Damage can be prevented or modified by various effects:
#![allow(unused)] fn main() { fn apply_damage_prevention_effects( assignment: &mut DamageAssignment, prevention_query: &Query<&PreventDamage> ) { if let Ok(prevent) = prevention_query.get(assignment.target) { let prevented = prevent.amount.min(assignment.amount); assignment.amount -= prevented; } } }
Damage Redirection
Some effects can redirect damage to different entities:
#![allow(unused)] fn main() { fn apply_damage_redirection( assignment: &mut DamageAssignment, redirection_query: &Query<&RedirectDamage> ) { if let Ok(redirect) = redirection_query.get(assignment.target) { // Change the target of the damage assignment.target = redirect.new_target; // Adjust commander damage flag if needed assignment.is_commander_damage = is_commander_entity(redirect.new_target); } } }
Testing Strategy
Unit Tests
#![allow(unused)] fn main() { #[test] fn test_basic_combat_damage() { // Set up test world let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, combat_damage_system); // Create attacker and defender let attacker = app.world.spawn(( Creature { power: 3, toughness: 3, }, Attacking { defending: player_entity }, )).id(); let defender = app.world.spawn(( Player {}, Health { current: 20, maximum: 20 }, CommanderDamage::default(), )).id(); // Process combat damage app.update(); // Verify damage was dealt let health = app.world.get::<Health>(defender).unwrap(); assert_eq!(health.current, 17); } #[test] fn test_first_strike_damage() { // Set up test world with first strike creatures // ... } #[test] fn test_blocked_creature_damage() { // Test damage assignment with blockers // ... } #[test] fn test_commander_damage_tracking() { // Test commander damage accumulation // ... } }
Integration Tests
#![allow(unused)] fn main() { #[test] fn test_full_combat_sequence() { // Set up a complete combat sequence with attackers, blockers, // and damage calculation // ... } #[test] fn test_combat_with_damage_prevention() { // Test combat with damage prevention effects // ... } #[test] fn test_combat_with_replacement_effects() { // Test combat with damage replacement effects // ... } }
Edge Case Tests
#![allow(unused)] fn main() { #[test] fn test_damage_to_indestructible() { // Test damage to indestructible creatures // ... } #[test] fn test_damage_with_deathtouch_and_trample() { // Test the interaction of deathtouch and trample // ... } #[test] fn test_damage_redirection() { // Test redirection of combat damage // ... } }
Performance Considerations
-
Batch Damage Processing: Process all damage assignments simultaneously for better performance.
-
Damage Event Optimization: Use a more efficient event system for damage events to avoid spawning entities.
-
Damage Assignment Caching: Cache damage assignments to avoid recalculating them.
-
Parallel Processing: Use parallel processing for damage assignment when appropriate.
Conclusion
The Combat Damage step is a complex but crucial part of the Commander game engine. Proper implementation ensures fair and accurate damage calculation, especially for tracking commander damage which is a key victory condition in the format. By handling special abilities like first strike, double strike, trample, and deathtouch correctly, we create a robust combat system that faithfully represents the Magic: The Gathering rules while maintaining good performance.
End of Combat
Overview
The End of Combat step is the final phase of the combat sequence where "at end of combat" triggered abilities are put on the stack and resolved. This step also includes important cleanup activities that return the game state to normal after combat concludes. In Commander, this step is particularly important for handling multiplayer-specific combat interactions and resetting the combat state.
This document outlines the implementation details, edge cases, and testing strategies for the End of Combat step in our Commander engine.
Core Concepts
End of Combat Flow
The End of Combat step follows this general sequence:
- "At end of combat" triggered abilities are put on the stack
- Priority is passed to players in turn order
- Once all players pass priority and the stack is empty, combat cleanup occurs:
- "Until end of combat" effects end
- Combat-specific statuses are removed
- The game advances to the Postcombat Main Phase
Combat Cleanup
After the End of Combat step is complete, several important cleanup tasks must occur:
- Remove all attacking and blocking statuses from creatures
- End "until end of combat" effects
- Clear combat damage tracking from the current turn
- Reset any combat-specific flags or state
Implementation Design
Data Structures
#![allow(unused)] fn main() { // Component for tracking "until end of combat" effects #[derive(Component)] struct UntilEndOfCombat { // Any data needed for the effect } // System resource for managing the end of combat step struct EndOfCombatSystem { triggers_processed: bool, } }
End of Combat System
#![allow(unused)] fn main() { fn end_of_combat_system( mut commands: Commands, mut combat_system: ResMut<CombatSystem>, turn_manager: Res<TurnManager>, attacker_query: Query<Entity, With<Attacking>>, blocker_query: Query<Entity, With<Blocking>>, end_of_combat_effects: Query<Entity, With<UntilEndOfCombat>>, end_of_combat_triggers: Query<(Entity, &EndOfCombatTrigger)>, // Other system parameters ) { // Only run during end of combat step if !matches!(turn_manager.current_phase, Phase::Combat(CombatStep::End)) { return; } // Process "at end of combat" triggers if not already processed if !combat_system.end_of_combat.triggers_processed { for (entity, trigger) in end_of_combat_triggers.iter() { // Create trigger event commands.spawn(TriggerEvent { source: entity, trigger_type: TriggerType::EndOfCombat, }); } combat_system.end_of_combat.triggers_processed = true; return; // Exit to allow triggers to be processed } // If we've processed triggers and the stack is empty, perform combat cleanup if combat_system.end_of_combat.triggers_processed && turn_manager.stack_is_empty() { // Remove attacking status from all attackers for entity in attacker_query.iter() { commands.entity(entity).remove::<Attacking>(); } // Remove blocking status from all blockers for entity in blocker_query.iter() { commands.entity(entity).remove::<Blocking>(); } // End "until end of combat" effects for entity in end_of_combat_effects.iter() { commands.entity(entity).remove::<UntilEndOfCombat>(); } // Reset combat system state combat_system.reset(); // Combat complete, ready to advance to postcombat main phase } } }
Combat System Reset
#![allow(unused)] fn main() { impl CombatSystem { pub fn reset(&mut self) { self.attackers.clear(); self.blockers.clear(); self.damage_assignments.clear(); self.first_strike_round_completed = false; self.begin_combat.triggers_processed = false; self.declare_attackers.triggers_processed = false; self.declare_blockers.triggers_processed = false; self.end_of_combat.triggers_processed = false; } } }
Special Cases and Edge Scenarios
Delayed End of Combat Triggers
Some effects create delayed triggered abilities that trigger at the end of combat:
#![allow(unused)] fn main() { fn create_delayed_end_of_combat_trigger( commands: &mut Commands, source: Entity, effect: impl Fn(&mut Commands) + Send + Sync + 'static ) { commands.spawn(( DelayedTrigger { source, trigger_type: TriggerType::EndOfCombat, effect: Box::new(effect), }, )); } }
Regeneration Shield Cleanup
Regeneration shields that were used during combat should be removed:
#![allow(unused)] fn main() { fn cleanup_regeneration_shields( mut commands: Commands, regeneration_query: Query<(Entity, &RegenerationShield)> ) { for (entity, shield) in regeneration_query.iter() { if shield.used { commands.entity(entity).remove::<RegenerationShield>(); } } } }
"Until End of Combat" Effects
Effects that last until end of combat need special handling:
#![allow(unused)] fn main() { fn apply_until_end_of_combat_effect( commands: &mut Commands, target: Entity, effect_data: EffectData ) { // Apply the effect commands.entity(target).insert(effect_data.component); // Mark it to be removed at end of combat commands.entity(target).insert(UntilEndOfCombat { // Any necessary data }); } }
Phasing During End of Combat
Creatures that phase out during combat might need special handling during end of combat:
#![allow(unused)] fn main() { fn handle_phased_combat_participants( mut commands: Commands, phased_attackers: Query<Entity, (With<Attacking>, With<PhasedOut>)>, phased_blockers: Query<Entity, (With<Blocking>, With<PhasedOut>)> ) { // Remove combat status from phased creatures too for entity in phased_attackers.iter() { commands.entity(entity).remove::<Attacking>(); } for entity in phased_blockers.iter() { commands.entity(entity).remove::<Blocking>(); } } }
Testing Strategy
Unit Tests
#![allow(unused)] fn main() { #[test] fn test_basic_end_of_combat_cleanup() { // Set up test world let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, end_of_combat_system); // Create attackers and blockers let attacker = app.world.spawn(( Creature { power: 3, toughness: 3 }, Attacking { defending: player_entity }, )).id(); let blocker = app.world.spawn(( Creature { power: 2, toughness: 2 }, Blocking { blocked_attackers: vec![attacker] }, )).id(); // Simulate end of combat step app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::End); app.world.resource_mut::<CombatSystem>().end_of_combat.triggers_processed = true; // Process end of combat app.update(); // Verify cleanup assert!(!app.world.entity(attacker).contains::<Attacking>()); assert!(!app.world.entity(blocker).contains::<Blocking>()); } #[test] fn test_end_of_combat_triggers() { // Test triggers that happen at end of combat // ... } #[test] fn test_until_end_of_combat_effects() { // Test that effects with "until end of combat" duration are removed // ... } }
Integration Tests
#![allow(unused)] fn main() { #[test] fn test_full_combat_sequence_with_end_phase() { // Test a complete combat sequence from beginning to end // ... } #[test] fn test_end_of_combat_with_replacement_effects() { // Test end of combat with replacement effects that modify cleanup // ... } }
Edge Case Tests
#![allow(unused)] fn main() { #[test] fn test_end_of_combat_with_phased_creatures() { // Test end of combat with phased creatures // ... } #[test] fn test_end_of_combat_when_player_loses() { // Test what happens if a player loses during end of combat // ... } #[test] fn test_end_of_combat_with_delayed_triggers() { // Test delayed triggers that happen at end of combat // ... } }
Performance Considerations
-
Batch Removal Operations: Group similar removal operations together for better performance.
-
Minimize Query Iterations: Structure queries to minimize iterations over entities.
-
State Reset Optimization: Efficiently reset the combat state without unnecessary operations.
-
Effect Tracking: Use an efficient system for tracking and removing "until end of combat" effects.
Conclusion
The End of Combat step is a critical transition point in the Commander game flow. Proper implementation ensures that combat-related statuses and effects are appropriately cleaned up, and the game state is correctly prepared for the next phase. By handling all "at end of combat" triggers and cleanup operations correctly, we create a robust and reliable combat system that maintains game state integrity while supporting all the complex interactions present in the Commander format.
Combat Abilities
Overview
Magic: The Gathering includes numerous abilities that specifically interact with combat. In Commander, these abilities gain additional complexity due to the multiplayer nature of the format and the special rules surrounding commander damage. This document details how combat-specific abilities are implemented and tested in our game engine.
Combat Keywords
Combat keywords are implemented through a combination of component flags and systems that check for these keywords during the appropriate combat steps.
#![allow(unused)] fn main() { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Keyword { // Combat-specific keywords FirstStrike, DoubleStrike, Deathtouch, Trample, Vigilance, Menace, Flying, Reach, Indestructible, Lifelink, // Other keywords... } // Component that stores a creature's abilities #[derive(Component, Clone)] pub struct Abilities(pub Vec<Ability>); // Different ability types #[derive(Debug, Clone)] pub enum Ability { Keyword(Keyword), Triggered(Trigger, Effect), Activated(ActivationCost, Effect), Static(StaticEffect), // Other ability types... } // Implement utility functions for checking abilities impl Creature { pub fn has_ability(&self, ability: Ability) -> bool { // Implementation details omitted for brevity false } pub fn has_keyword(&self, keyword: Keyword) -> bool { // Check if the creature has the specified keyword if let Ok(abilities) = abilities_query.get(self.entity) { abilities.0.iter().any(|ability| { if let Ability::Keyword(kw) = ability { *kw == keyword } else { false } }) } else { false } } } }
Keyword Implementation Systems
Each combat keyword has a dedicated system that implements its effect:
First Strike and Double Strike
#![allow(unused)] fn main() { pub fn first_strike_damage_system( mut combat_system: ResMut<CombatSystem>, turn_manager: Res<TurnManager>, creature_query: Query<(Entity, &Creature)>, mut game_events: EventWriter<GameEvent>, ) { // Only run during the first strike damage step if turn_manager.current_phase != Phase::Combat(CombatStep::FirstStrike) { return; } // Get all attackers with first strike or double strike let first_strike_attackers = combat_system.attackers .iter() .filter_map(|(attacker, attack_data)| { if let Ok((entity, creature)) = creature_query.get(*attacker) { if creature.has_keyword(Keyword::FirstStrike) || creature.has_keyword(Keyword::DoubleStrike) { return Some((attacker, attack_data)); } } None }) .collect::<Vec<_>>(); // Get all blockers with first strike or double strike let first_strike_blockers = combat_system.blockers .iter() .filter_map(|(blocker, block_data)| { if let Ok((entity, creature)) = creature_query.get(*blocker) { if creature.has_keyword(Keyword::FirstStrike) || creature.has_keyword(Keyword::DoubleStrike) { return Some((blocker, block_data)); } } None }) .collect::<Vec<_>>(); // Process first strike combat damage // Implementation details omitted for brevity // Emit event for first strike damage game_events.send(GameEvent::FirstStrikeDamageDealt { attackers: first_strike_attackers.len(), blockers: first_strike_blockers.len(), }); } }
Deathtouch
#![allow(unused)] fn main() { pub fn apply_deathtouch_system( combat_system: Res<CombatSystem>, creature_query: Query<(Entity, &Creature)>, mut commands: Commands, ) { // Find all creatures with deathtouch that dealt damage let deathtouch_creatures = combat_system.combat_history .iter() .filter_map(|event| { if let CombatEvent::DamageDealt { source, target, amount, .. } = event { if *amount > 0 { if let Ok((entity, creature)) = creature_query.get(*source) { if creature.has_keyword(Keyword::Deathtouch) { return Some((source, target)); } } } } None }) .collect::<Vec<_>>(); // Apply destroy effect to creatures that were damaged by deathtouch for (source, target) in deathtouch_creatures { // Only apply to creatures, not players or planeswalkers if creature_query.contains(*target) { // Mark creature as destroyed commands.entity(*target).insert(Destroyed { source: *source, reason: DestructionReason::Deathtouch, }); } } } }
Trample
#![allow(unused)] fn main() { pub fn apply_trample_damage_system( mut combat_system: ResMut<CombatSystem>, creature_query: Query<(Entity, &Creature)>, blocker_toughness_query: Query<&Creature>, player_query: Query<(Entity, &mut Player)>, mut game_events: EventWriter<GameEvent>, ) { // Find all creatures with trample that are being blocked for (attacker, attack_data) in combat_system.attackers.iter() { // Only process if the attacker has trample if let Ok((entity, creature)) = creature_query.get(*attacker) { if !creature.has_keyword(Keyword::Trample) { continue; } // Check if this attacker is blocked let blockers: Vec<Entity> = combat_system.blockers.iter() .filter_map(|(blocker, block_data)| { if block_data.blocked_attackers.contains(attacker) { Some(*blocker) } else { None } }) .collect(); // Only apply trample if the creature is blocked if !blockers.is_empty() { // Calculate total blocker toughness let total_blocker_toughness: u32 = blockers.iter() .filter_map(|blocker| { blocker_toughness_query.get(*blocker).ok().map(|creature| creature.toughness) }) .sum(); // Calculate trample damage (attacker power - total blocker toughness) let trample_damage = creature.power.saturating_sub(total_blocker_toughness); // Apply trample damage to defending player if there's excess damage if trample_damage > 0 { // Only apply to players, not planeswalkers for (player_entity, mut player) in player_query.iter_mut() { if player_entity == attack_data.defender { // Apply the damage player.life_total -= trample_damage as i32; // Record the damage event combat_system.combat_history.push_back(CombatEvent::DamageDealt { source: *attacker, target: player_entity, amount: trample_damage, is_commander_damage: attack_data.is_commander, }); // Check if player lost from trample damage if player.life_total <= 0 { game_events.send(GameEvent::PlayerLost { player: player_entity, reason: LossReason::ZeroLife, }); } break; } } } } } } } }
Flying and Reach
#![allow(unused)] fn main() { pub fn validate_blocks_flying_system( mut combat_system: ResMut<CombatSystem>, creature_query: Query<(Entity, &Creature)>, mut block_events: EventWriter<BlockValidationEvent>, ) { // Check for illegal blocks involving flying creatures let mut illegal_blocks = Vec::new(); for (blocker, block_data) in combat_system.blockers.iter() { // Get blocker details if let Ok((blocker_entity, blocker_creature)) = creature_query.get(*blocker) { // Check if blocker has reach or flying let can_block_flying = blocker_creature.has_keyword(Keyword::Flying) || blocker_creature.has_keyword(Keyword::Reach); // Check all attackers this creature is blocking for attacker in &block_data.blocked_attackers { if let Ok((attacker_entity, attacker_creature)) = creature_query.get(*attacker) { // If attacker has flying and blocker can't block flying, this is illegal if attacker_creature.has_keyword(Keyword::Flying) && !can_block_flying { illegal_blocks.push((*blocker, *attacker)); } } } } } // Remove illegal blocks for (blocker, attacker) in illegal_blocks { // Remove the block relationship if let Some(mut block_data) = combat_system.blockers.get_mut(&blocker) { block_data.blocked_attackers.retain(|a| *a != attacker); // If no more attackers, remove blocker entry if block_data.blocked_attackers.is_empty() { combat_system.blockers.remove(&blocker); } } // Emit event for illegal block block_events.send(BlockValidationEvent::IllegalBlock { blocker, attacker, reason: "Can't block flying".to_string(), }); } } }
Menace
#![allow(unused)] fn main() { pub fn validate_blocks_menace_system( mut combat_system: ResMut<CombatSystem>, creature_query: Query<(Entity, &Creature)>, mut block_events: EventWriter<BlockValidationEvent>, ) { // Find attackers with menace that are being blocked by only one creature let menace_violations = combat_system.attackers .iter() .filter_map(|(attacker, attack_data)| { // Check if attacker has menace if let Ok((entity, creature)) = creature_query.get(*attacker) { if creature.has_keyword(Keyword::Menace) { // Count blockers for this attacker let blocker_count = combat_system.blockers .values() .filter(|block_data| block_data.blocked_attackers.contains(attacker)) .count(); // Menace requires at least 2 blockers if blocker_count == 1 { // Find the single blocker let blocker = combat_system.blockers .iter() .find_map(|(blocker, block_data)| { if block_data.blocked_attackers.contains(attacker) { Some(*blocker) } else { None } }) .unwrap(); return Some((*attacker, blocker)); } } } None }) .collect::<Vec<_>>(); // Remove blocks that violate menace for (attacker, blocker) in menace_violations { // Remove the block relationship if let Some(mut block_data) = combat_system.blockers.get_mut(&blocker) { block_data.blocked_attackers.retain(|a| *a != attacker); // If no more attackers, remove blocker entry if block_data.blocked_attackers.is_empty() { combat_system.blockers.remove(&blocker); } } // Emit event for illegal block block_events.send(BlockValidationEvent::IllegalBlock { blocker, attacker, reason: "Menace requires at least two blockers".to_string(), }); } } }
Vigilance
#![allow(unused)] fn main() { pub fn apply_vigilance_system( combat_system: Res<CombatSystem>, creature_query: Query<(Entity, &Creature)>, mut commands: Commands, ) { // Find attackers with vigilance and ensure they don't get tapped for (attacker, _) in combat_system.attackers.iter() { if let Ok((entity, creature)) = creature_query.get(*attacker) { if creature.has_keyword(Keyword::Vigilance) { // Remove the Tapped component if it exists commands.entity(*attacker).remove::<Tapped>(); } } } } }
Triggered Combat Abilities
Triggered abilities are a major part of combat in Magic. These are implemented using a trigger system:
#![allow(unused)] fn main() { #[derive(Debug, Clone)] pub enum Trigger { // Combat-related triggers WhenAttacks { conditions: Vec<TriggerCondition> }, WhenBlocks { conditions: Vec<TriggerCondition> }, WhenBlocked { conditions: Vec<TriggerCondition> }, WhenDealsCombat { conditions: Vec<TriggerCondition> }, WheneverCreatureAttacks { conditions: Vec<TriggerCondition> }, WheneverCreatureBlocks { conditions: Vec<TriggerCondition> }, BeginningOfCombat { controller_condition: ControllerCondition }, EndOfCombat { controller_condition: ControllerCondition }, // Other triggers... } pub fn combat_trigger_system( combat_system: Res<CombatSystem>, turn_manager: Res<TurnManager>, mut stack: ResMut<Stack>, entity_query: Query<(Entity, &Card, &Abilities)>, ) { // Only run during the appropriate combat step if !matches!(turn_manager.current_phase, Phase::Combat(CombatStep::DeclareAttackers) | Phase::Combat(CombatStep::DeclareBlockers) | Phase::Combat(CombatStep::CombatDamage)) { return; } // Collect triggered abilities based on the current combat step let mut triggered_abilities = Vec::new(); // Check all entities with abilities for (entity, card, abilities) in entity_query.iter() { for ability in &abilities.0 { if let Ability::Triggered(trigger, effect) = ability { match trigger { // Check if we're in declare attackers and this is a "when attacks" trigger Trigger::WhenAttacks { conditions } if turn_manager.current_phase == Phase::Combat(CombatStep::DeclareAttackers) => { // Check if this entity is attacking if let Some(attack_data) = combat_system.attackers.get(&entity) { // Check if conditions are met if all_conditions_met(conditions, entity, attack_data.defender) { triggered_abilities.push((entity, effect.clone(), get_controller(entity))); } } }, // Similar checks for other triggers... _ => {} } } } } // Add triggered abilities to the stack in APNAP order let active_player = turn_manager.get_active_player(); let ordered_triggers = order_triggers_by_apnap(triggered_abilities, active_player); for (source, effect, controller) in ordered_triggers { stack.push(StackItem::Ability { source, effect, controller, }); } } }
Special Combat Abilities
Ninjutsu
#![allow(unused)] fn main() { pub fn handle_ninjutsu_system( mut combat_system: ResMut<CombatSystem>, turn_manager: Res<TurnManager>, mut commands: Commands, card_query: Query<(Entity, &Card, &Abilities, Option<&Commander>)>, mut activation_events: EventReader<NinjutsuActivationEvent>, mut creature_query: Query<(Entity, &mut Creature)>, ) { // Only active during declare attackers step after attacks are declared if turn_manager.current_phase != Phase::Combat(CombatStep::DeclareAttackers) || combat_system.attackers.is_empty() { return; } // Process ninjutsu activations for event in activation_events.iter() { if let Ok((entity, card, abilities, commander)) = card_query.get(event.ninja) { // Verify card has ninjutsu or commander ninjutsu let has_ninjutsu = abilities.0.iter().any(|ability| { matches!(ability, Ability::Activated(ActivationCost::Ninjutsu(_), _)) }); let has_commander_ninjutsu = commander.is_some() && abilities.0.iter().any(|ability| { matches!(ability, Ability::Activated(ActivationCost::CommanderNinjutsu(_), _)) }); if has_ninjutsu || has_commander_ninjutsu { // Verify unblocked attacker if !combat_system.attackers.contains_key(&event.unblocked_attacker) { continue; } // Verify attacker is unblocked let is_blocked = combat_system.blockers.values().any(|block_data| { block_data.blocked_attackers.contains(&event.unblocked_attacker) }); if is_blocked { continue; } // Get defender let defender = combat_system.attackers[&event.unblocked_attacker].defender; // Return unblocked attacker to hand // Implementation details omitted for brevity // Put ninja onto battlefield tapped and attacking // Implementation details omitted for brevity // Update combat system combat_system.attackers.remove(&event.unblocked_attacker); combat_system.attackers.insert(event.ninja, AttackData { attacker: event.ninja, defender, is_commander: commander.is_some(), requirements: Vec::new(), restrictions: Vec::new(), }); } } } } }
Protection
#![allow(unused)] fn main() { pub fn apply_protection_system( mut combat_system: ResMut<CombatSystem>, creature_query: Query<(Entity, &Creature, &ActiveEffects)>, mut combat_events: EventWriter<CombatEvent>, ) { // Check for protection effects let mut invalid_blocks = Vec::new(); let mut prevented_damage = Vec::new(); // Check blocks against protection for (blocker, block_data) in combat_system.blockers.iter() { if let Ok((blocker_entity, _, effects)) = creature_query.get(*blocker) { for attacker in &block_data.blocked_attackers { if has_protection_from(*blocker, *attacker, &creature_query) { invalid_blocks.push((*blocker, *attacker)); } } } } // Check damage against protection let damage_events = combat_system.combat_history .iter() .filter_map(|event| { if let CombatEvent::DamageDealt { source, target, amount, is_commander_damage } = event { Some((*source, *target, *amount, *is_commander_damage)) } else { None } }) .collect::<Vec<_>>(); for (source, target, amount, is_commander_damage) in damage_events { if has_protection_from(target, source, &creature_query) { prevented_damage.push((source, target, amount, is_commander_damage)); } } // Remove invalid blocks for (blocker, attacker) in invalid_blocks { if let Some(mut block_data) = combat_system.blockers.get_mut(&blocker) { block_data.blocked_attackers.retain(|a| *a != attacker); } } // Record damage prevention events for (source, target, amount, is_commander_damage) in prevented_damage { combat_events.send(CombatEvent::DamagePrevented { source, target, amount, reason: PreventionReason::Protection, }); } } // Helper function to check if an entity has protection from another fn has_protection_from( protected: Entity, source: Entity, creature_query: &Query<(Entity, &Creature, &ActiveEffects)>, ) -> bool { if let Ok((_, _, effects)) = creature_query.get(protected) { for effect in &effects.0 { match effect { Effect::ProtectionFromColor(color) => { // Check if source has the protected color if let Ok((_, source_creature, _)) = creature_query.get(source) { if source_creature.colors.contains(color) { return true; } } }, Effect::ProtectionFromCreatureType(creature_type) => { // Check if source has the protected creature type if let Ok((_, source_creature, _)) = creature_query.get(source) { if source_creature.types.contains(creature_type) { return true; } } }, Effect::ProtectionFromPlayer(player) => { // Check if source is controlled by the protected player // Implementation details omitted for brevity }, Effect::ProtectionFromEverything => { return true; }, _ => {} } } } false } }
Edge Cases
Multiple Combats
Some cards create additional combat phases. These need special handling:
#![allow(unused)] fn main() { pub fn handle_additional_combat_phases( mut turn_manager: ResMut<TurnManager>, mut combat_system: ResMut<CombatSystem>, mut creature_query: Query<(Entity, &mut Creature)>, mut phase_events: EventReader<PhaseEvent>, ) { // Check for additional combat phase events for event in phase_events.iter() { if let PhaseEvent::AdditionalPhase { phase, source } = event { if matches!(phase, Phase::Combat(_)) { // Reset combat state for the new phase combat_system.reset(); combat_system.is_additional_combat_phase = true; // Reset attacked/blocked flags on creatures for (entity, mut creature) in creature_query.iter_mut() { creature.attacking = None; creature.blocking.clear(); } // Update turn manager turn_manager.additional_phases.push_back((*phase, *source)); } } } } }
Changing Abilities During Combat
Creatures can gain or lose abilities during combat:
#![allow(unused)] fn main() { pub fn update_combat_abilities_system( mut creature_query: Query<(Entity, &mut Creature, &ActiveEffects)>, mut combat_system: ResMut<CombatSystem>, mut ability_events: EventReader<AbilityChangeEvent>, ) { // Process ability change events for event in ability_events.iter() { if let AbilityChangeEvent::GainedKeyword { entity, keyword } = event { // Update entity if it's involved in combat if combat_system.attackers.contains_key(entity) || combat_system.blockers.contains_key(entity) { // Special handling for combat-relevant keywords match keyword { Keyword::FirstStrike | Keyword::DoubleStrike => { // May need to recalculate damage for first strike step if combat_system.active_combat_step == Some(CombatStep::DeclareBlockers) { // Flag that first strike needs to be checked combat_system.first_strike_recalculation_needed = true; } }, Keyword::Flying => { // May need to recalculate blocks for flying if let Some(block_data) = combat_system.blockers.get(entity) { // Check if this creature is now blocking a flyer illegally // Implementation details omitted for brevity } }, Keyword::Vigilance => { // If creature already attacked, untap it if combat_system.attackers.contains_key(entity) { // Implementation details omitted for brevity } }, // Handle other keywords... _ => {} } } } else if let AbilityChangeEvent::LostKeyword { entity, keyword } = event { // Similar handling for losing keywords // Implementation details omitted for brevity } } } }
Testing Strategy
Unit Tests for Combat Abilities
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn test_deathtouch() { let mut app = App::new(); app.add_systems(Update, apply_deathtouch_system); // Setup test entities with deathtouch let attacker = app.world.spawn(( Card::default(), Creature { power: 1, toughness: 1, ..Default::default() }, Abilities(vec![Ability::Keyword(Keyword::Deathtouch)]), )).id(); let blocker = app.world.spawn(( Card::default(), Creature { power: 5, toughness: 5, ..Default::default() }, )).id(); // Create combat history with damage event let mut combat_system = CombatSystem::default(); combat_system.combat_history.push_back(CombatEvent::DamageDealt { source: attacker, target: blocker, amount: 1, is_commander_damage: false, }); app.insert_resource(combat_system); // Run the system app.update(); // Verify blocker was destroyed by deathtouch assert!(app.world.entity(blocker).contains::<Destroyed>()); } #[test] fn test_trample() { let mut app = App::new(); app.add_systems(Update, apply_trample_damage_system); // Setup test entities let player = app.world.spawn(( Player { life_total: 40, commander_damage: HashMap::new(), ..Default::default() }, )).id(); let attacker = app.world.spawn(( Card::default(), Creature { power: 5, toughness: 5, ..Default::default() }, Abilities(vec![Ability::Keyword(Keyword::Trample)]), )).id(); let blocker = app.world.spawn(( Card::default(), Creature { power: 2, toughness: 2, ..Default::default() }, )).id(); // Create combat system with attacker and blocker let mut combat_system = CombatSystem::default(); combat_system.attackers.insert(attacker, AttackData { attacker, defender: player, is_commander: false, requirements: Vec::new(), restrictions: Vec::new(), }); combat_system.blockers.insert(blocker, BlockData { blocker, blocked_attackers: vec![attacker], requirements: Vec::new(), restrictions: Vec::new(), }); app.insert_resource(combat_system); // Run the system app.update(); // Verify trample damage was dealt to player (5 power - 2 toughness = 3 damage) let player_component = app.world.entity(player).get::<Player>().unwrap(); assert_eq!(player_component.life_total, 40 - 3); // Verify combat history contains damage event let combat_system = app.world.resource::<CombatSystem>(); assert!(combat_system.combat_history.iter().any(|event| { matches!(event, CombatEvent::DamageDealt { source, target, amount, .. } if *source == attacker && *target == player && *amount == 3) })); } #[test] fn test_flying_and_reach() { let mut app = App::new(); app.add_systems(Update, validate_blocks_flying_system); // Setup test entities let flying_creature = app.world.spawn(( Card::default(), Creature { power: 3, toughness: 3, ..Default::default() }, Abilities(vec![Ability::Keyword(Keyword::Flying)]), )).id(); let normal_creature = app.world.spawn(( Card::default(), Creature { power: 2, toughness: 2, ..Default::default() }, )).id(); let reach_creature = app.world.spawn(( Card::default(), Creature { power: 1, toughness: 4, ..Default::default() }, Abilities(vec![Ability::Keyword(Keyword::Reach)]), )).id(); // Create combat system with illegal block let mut combat_system = CombatSystem::default(); combat_system.attackers.insert(flying_creature, AttackData { attacker: flying_creature, defender: Entity::from_raw(999), // Dummy defender is_commander: false, requirements: Vec::new(), restrictions: Vec::new(), }); combat_system.blockers.insert(normal_creature, BlockData { blocker: normal_creature, blocked_attackers: vec![flying_creature], requirements: Vec::new(), restrictions: Vec::new(), }); combat_system.blockers.insert(reach_creature, BlockData { blocker: reach_creature, blocked_attackers: vec![flying_creature], requirements: Vec::new(), restrictions: Vec::new(), }); app.insert_resource(combat_system); // Set up event listener app.add_event::<BlockValidationEvent>(); // Run the system app.update(); // Verify normal creature can't block the flyer let combat_system = app.world.resource::<CombatSystem>(); assert!(!combat_system.blockers.get(&normal_creature) .map_or(false, |data| data.blocked_attackers.contains(&flying_creature))); // Verify reach creature can still block the flyer assert!(combat_system.blockers.get(&reach_creature) .map_or(false, |data| data.blocked_attackers.contains(&flying_creature))); } // Additional tests... } }
Integration Tests for Combat Abilities
#![allow(unused)] fn main() { #[cfg(test)] mod integration_tests { use super::*; #[test] fn test_keyword_interactions() { let mut builder = CombatScenarioBuilder::new(); // Setup a combat scenario with multiple keyword interactions let opponent = builder.add_player(); // Flying attacker with lifelink and double strike let attacker = builder.add_attacker(3, 3, builder.active_player, false); builder.add_effect(attacker, Effect::Keyword(Keyword::Flying)); builder.add_effect(attacker, Effect::Keyword(Keyword::Lifelink)); builder.add_effect(attacker, Effect::Keyword(Keyword::DoubleStrike)); // Normal blocker can't block flying let blocker1 = builder.add_blocker(2, 2, opponent); // Reach blocker can block flying let blocker2 = builder.add_blocker(1, 1, opponent); builder.add_effect(blocker2, Effect::Keyword(Keyword::Reach)); // Set up attacks and blocks builder.declare_attacks(vec![(attacker, opponent)]); builder.declare_blocks(vec![(blocker2, vec![attacker])]); // Execute combat let result = builder.execute(); // Verify: // 1. First strike damage killed blocker2 // 2. Double strike allowed second hit to player // 3. Lifelink gained life // Check blocker died let blocker2_status = result.creature_status.get(&blocker2).unwrap(); assert!(blocker2_status.destroyed); // Check player took damage from second hit (double strike) assert_eq!(result.player_life[&opponent], 40 - 3); // Check controller gained life from lifelink let active_player_life = result.player_life[&builder.active_player]; assert_eq!(active_player_life, 40 + 6); // 3 damage twice from double strike } #[test] fn test_triggered_abilities() { // Test implementation for combat-triggered abilities // Implementation details omitted for brevity } #[test] fn test_ninjutsu() { // Test implementation for ninjutsu // Implementation details omitted for brevity } // Additional integration tests... } }
Combos and Interactions
The combat system needs to handle complex ability interactions:
#![allow(unused)] fn main() { pub fn handle_ability_interactions( combat_system: Res<CombatSystem>, entity_query: Query<(Entity, &Creature, &ActiveEffects)>, mut destroy_events: EventWriter<DestroyEvent>, mut damage_events: EventWriter<DamageEvent>, ) { // Track relevant ability combinations let mut indestructible_entities = HashSet::new(); let mut damage_redirection_map = HashMap::new(); let mut regeneration_shields = HashMap::new(); // Collect all combat-relevant abilities for (entity, _, effects) in entity_query.iter() { for effect in &effects.0 { match effect { Effect::Indestructible => { indestructible_entities.insert(entity); }, Effect::RedirectDamage { target } => { damage_redirection_map.insert(entity, *target); }, Effect::RegenerationShield => { regeneration_shields.entry(entity).or_insert(0) += 1; }, // Handle other effects _ => {} } } } // Process combat damage events considering special abilities for event in combat_system.combat_history.iter() { if let CombatEvent::DamageDealt { source, target, amount, is_commander_damage } = event { // Check for damage redirection let actual_target = damage_redirection_map.get(target).copied().unwrap_or(*target); // Check if damage would be lethal if let Ok((_, creature, _)) = entity_query.get(actual_target) { if creature.toughness <= creature.damage + amount { // Check for indestructible if indestructible_entities.contains(&actual_target) { // Creature survives despite lethal damage continue; } // Check for regeneration shield if let Some(shields) = regeneration_shields.get_mut(&actual_target) { if *shields > 0 { // Use a regeneration shield *shields -= 1; // Emit regeneration event // Implementation details omitted continue; } } // No protection, creature is destroyed destroy_events.send(DestroyEvent { entity: actual_target, source: *source, reason: DestructionReason::LethalDamage, }); } } } } } }
Conclusion
Combat abilities add significant complexity to the Commander format, requiring careful implementation and testing. The systems described here work together to handle the various keywords, triggers, and special cases that can arise during combat. By properly implementing and testing these abilities, we ensure that the combat system correctly follows the rules of Magic: The Gathering while providing the strategic depth that makes Commander such a popular format.
Combat Tests
This section contains tests related to the combat system in the Commander format, focusing on both the standard combat rules and Commander-specific interactions.
Subsections
Edge Cases
Tests for complex and edge case combat scenarios, including Commander-specific interactions and intricate card combinations.
Phasing
Tests related to phasing effects during combat, including their impact on attackers, blockers, and combat damage.
Pump Spells
Tests for combat interactions with pump spells and other power/toughness modifying effects that occur during combat.
Combat Edge Cases Tests
This section contains tests for complex and edge case scenarios in the combat system that are specific to the Commander format.
Tests
Commander-Specific Interactions
Tests for combat mechanics that interact with Commander-specific rules, such as:
- Commander damage tracking
- Combat abilities on Commander cards
- Commander-specific combat triggers
Complex Interactions
Tests for intricate card combinations and rules interactions during combat, including:
- Multiple blocking and damage assignment
- Replacement effects during combat
- Unusual attack and block restrictions
- Multiple combat phases
- Layer system interactions affecting combat
Commander-Specific Combat Tests
Overview
This document outlines test cases for Commander-specific combat scenarios, focusing on the unique rules and edge cases that arise in the Commander format. These tests are critical for ensuring our Commander engine correctly handles format-specific interactions during combat.
Test Case: Commander Damage Tracking
Test: Multiple Sources of Commander Damage
#![allow(unused)] fn main() { #[test] fn test_multiple_commanders_damage_tracking() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (combat_damage_system, state_based_actions_system)); // Create player let player = app.world.spawn(( Player {}, Health { current: 40, maximum: 40 }, CommanderDamage::default(), )).id(); // Create commanders from different players let commander1 = app.world.spawn(( Creature { power: 5, toughness: 5 }, Commander { owner: opponent1 }, Attacking { defending: player }, )).id(); let commander2 = app.world.spawn(( Creature { power: 3, toughness: 3 }, Commander { owner: opponent2 }, Attacking { defending: player }, )).id(); let opponent1 = app.world.spawn(Player {}).id(); let opponent2 = app.world.spawn(Player {}).id(); // Process combat damage for first attack app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage); app.update(); // Verify commander damage was tracked separately let cmd_damage = app.world.get::<CommanderDamage>(player).unwrap(); assert_eq!(cmd_damage.get_damage(commander1), 5); assert_eq!(cmd_damage.get_damage(commander2), 3); assert_eq!(cmd_damage.total_damage(), 8); // Player's health should also be reduced let health = app.world.get::<Health>(player).unwrap(); assert_eq!(health.current, 32); // 40 - 5 - 3 = 32 // More damage from commander1 in a later combat app.world.entity_mut(commander1).insert( DamageEvent { source: commander1, target: player, amount: 5, is_combat_damage: true, } ); // Process the damage app.update(); // Check state-based actions after damage app.update(); // Verify commander damage accumulated correctly let cmd_damage_after = app.world.get::<CommanderDamage>(player).unwrap(); assert_eq!(cmd_damage_after.get_damage(commander1), 10); assert_eq!(cmd_damage_after.get_damage(commander2), 3); // Verify player still alive (no commander has dealt 21+ damage yet) assert!(app.world.entity(player).contains::<Health>()); // Deal more damage from commander1 to reach lethal commander damage app.world.entity_mut(commander1).insert( DamageEvent { source: commander1, target: player, amount: 11, is_combat_damage: true, } ); // Process the damage app.update(); // Check state-based actions after damage app.update(); // Verify player lost due to commander damage let cmd_damage_final = app.world.get::<CommanderDamage>(player).unwrap(); assert_eq!(cmd_damage_final.get_damage(commander1), 21); assert!(app.world.entity(player).contains::<PlayerEliminated>()); } }
Test: Commander Damage from Copied Commander
#![allow(unused)] fn main() { #[test] fn test_commander_damage_from_clone() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (combat_damage_system, clone_effects_system)); // Create player let player = app.world.spawn(( Player {}, Health { current: 40, maximum: 40 }, CommanderDamage::default(), )).id(); // Create commander let commander = app.world.spawn(( Creature { power: 5, toughness: 5 }, Commander { owner: opponent }, CreatureCard { controller: opponent }, )).id(); let opponent = app.world.spawn(Player {}).id(); // Create clone of the commander let clone = app.world.spawn(( Creature { power: 5, toughness: 5 }, CloneEffect { source: commander }, CreatureCard { controller: opponent }, )).id(); // Make clone attack app.world.entity_mut(clone).insert(Attacking { defending: player }); // Process combat damage app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage); app.update(); // Verify damage from clone is NOT commander damage let cmd_damage = app.world.get::<CommanderDamage>(player).unwrap(); assert_eq!(cmd_damage.get_damage(commander), 0); assert_eq!(cmd_damage.get_damage(clone), 0); // Player's health should still be reduced let health = app.world.get::<Health>(player).unwrap(); assert_eq!(health.current, 35); // 40 - 5 = 35 } }
Test Case: Commander Combat Abilities
Test: Commander with Partner
#![allow(unused)] fn main() { #[test] fn test_partner_commanders_in_combat() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (combat_damage_system, state_based_actions_system)); // Create player let player = app.world.spawn(( Player {}, Health { current: 40, maximum: 40 }, CommanderDamage::default(), )).id(); // Create partner commanders let partner1 = app.world.spawn(( Creature { power: 3, toughness: 3 }, Commander { owner: opponent }, Partner {}, Attacking { defending: player }, )).id(); let partner2 = app.world.spawn(( Creature { power: 2, toughness: 2 }, Commander { owner: opponent }, Partner {}, Attacking { defending: player }, )).id(); let opponent = app.world.spawn(Player {}).id(); // Process combat damage app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage); app.update(); // Verify commander damage was tracked separately for each partner let cmd_damage = app.world.get::<CommanderDamage>(player).unwrap(); assert_eq!(cmd_damage.get_damage(partner1), 3); assert_eq!(cmd_damage.get_damage(partner2), 2); // Player needs to take 21 damage from a single partner to lose // Simulate multiple combats to test this for _ in 0..6 { app.world.entity_mut(partner1).insert( DamageEvent { source: partner1, target: player, amount: 3, is_combat_damage: true, } ); app.update(); } // Check state-based actions app.update(); // Verify player lost due to commander damage from one partner let cmd_damage_final = app.world.get::<CommanderDamage>(player).unwrap(); assert_eq!(cmd_damage_final.get_damage(partner1), 21); assert!(app.world.entity(player).contains::<PlayerEliminated>()); } }
Test: Commander with Combat Damage Triggers
#![allow(unused)] fn main() { #[test] fn test_commander_combat_damage_triggers() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (combat_damage_system, trigger_system)); // Create player let player = app.world.spawn(( Player {}, Health { current: 40, maximum: 40 }, CommanderDamage::default(), )).id(); // Create commander with combat damage trigger let commander = app.world.spawn(( Creature { power: 5, toughness: 5 }, Commander { owner: opponent }, Attacking { defending: player }, CombatDamageTrigger { effect: TriggerEffect::DrawCards { player: opponent, count: 1, } }, )).id(); let opponent = app.world.spawn(( Player {}, Library { cards: vec![card1, card2, card3] }, Hand { cards: vec![] }, )).id(); let card1 = app.world.spawn(Card {}).id(); let card2 = app.world.spawn(Card {}).id(); let card3 = app.world.spawn(Card {}).id(); // Process combat damage app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage); app.update(); // Process triggers app.update(); // Verify commander damage was tracked let cmd_damage = app.world.get::<CommanderDamage>(player).unwrap(); assert_eq!(cmd_damage.get_damage(commander), 5); // Verify combat damage trigger resolved let hand = app.world.get::<Hand>(opponent).unwrap(); assert_eq!(hand.cards.len(), 1); } }
Test Case: Multiplayer Combat Scenarios
Test: Attacking Multiple Players with Commander
#![allow(unused)] fn main() { #[test] fn test_attacking_multiple_players_with_commander() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (declare_attackers_system, combat_damage_system)); // Create players let player1 = app.world.spawn(( Player {}, Health { current: 40, maximum: 40 }, CommanderDamage::default(), )).id(); let player2 = app.world.spawn(( Player {}, Health { current: 40, maximum: 40 }, CommanderDamage::default(), )).id(); let active_player = app.world.spawn(( Player {}, ActivePlayer {}, )).id(); // Create commander with vigilance (can attack multiple players) let commander = app.world.spawn(( Creature { power: 5, toughness: 5 }, Commander { owner: active_player }, CreatureCard { controller: active_player }, Vigilance {}, )).id(); // Create additional attacker let other_attacker = app.world.spawn(( Creature { power: 3, toughness: 3 }, CreatureCard { controller: active_player }, )).id(); // Declare attackers for different players app.world.resource_mut::<AttackDeclaration>().declare_attacker(commander, player1); app.world.resource_mut::<AttackDeclaration>().declare_attacker(other_attacker, player2); // Process declare attackers app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::DeclareAttackers); app.update(); // Process combat damage app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage); app.update(); // Verify commander damage was tracked for player1 only let cmd_damage1 = app.world.get::<CommanderDamage>(player1).unwrap(); assert_eq!(cmd_damage1.get_damage(commander), 5); let cmd_damage2 = app.world.get::<CommanderDamage>(player2).unwrap(); assert_eq!(cmd_damage2.get_damage(commander), 0); // Verify health reduction for both players let health1 = app.world.get::<Health>(player1).unwrap(); assert_eq!(health1.current, 35); // 40 - 5 = 35 let health2 = app.world.get::<Health>(player2).unwrap(); assert_eq!(health2.current, 37); // 40 - 3 = 37 } }
Test: Goad Effect on Commander
#![allow(unused)] fn main() { #[test] fn test_goaded_commander() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (declare_attackers_system, attack_restrictions_system)); // Create players let player1 = app.world.spawn(Player {}).id(); let player2 = app.world.spawn(Player {}).id(); let player3 = app.world.spawn(Player {}).id(); // Create commander that's been goaded by player2 let commander = app.world.spawn(( Creature { power: 5, toughness: 5 }, Commander { owner: player1 }, CreatureCard { controller: player1 }, Goad { goaded_by: player2 }, )).id(); // Set up attack validation app.world.resource_mut::<TurnManager>().active_player = player1; app.world.resource_mut::<CombatSystem>().validate_attack = |attacker, defender, world| { // Basic validation - can't attack yourself let creature_card = world.get::<CreatureCard>(attacker).unwrap(); if creature_card.controller == defender { return Err(AttackError::IllegalTarget); } // Check goad restrictions if let Some(goad) = world.get::<Goad>(attacker) { if goad.goaded_by != defender && defender != player3 { return Err(AttackError::MustAttackGoader); } } Ok(()) }; // Try to attack player3 (allowed because not the goader) assert!(app.world.resource::<CombatSystem>() .validate_attack(commander, player3, &app.world) .is_ok()); // Try to attack player2 (allowed because this is the goader) assert!(app.world.resource::<CombatSystem>() .validate_attack(commander, player2, &app.world) .is_ok()); // Try to attack self (not allowed) assert!(app.world.resource::<CombatSystem>() .validate_attack(commander, player1, &app.world) .is_err()); // Commander must attack if able app.world.resource_mut::<AttackRequirements>().creatures_that_must_attack.push(commander); // Process attack requirements app.update(); // Verify commander is forced to attack assert!(app.world.get::<MustAttack>(commander).is_some()); } }
Test Case: Commander Zone Interactions
Test: Combat Damage Sending Commander to Command Zone
#![allow(unused)] fn main() { #[test] fn test_commander_to_command_zone_from_combat() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (combat_damage_system, state_based_actions_system, zone_change_system)); // Create commander let commander = app.world.spawn(( Creature { power: 3, toughness: 3 }, Commander { owner: player }, CreatureCard { controller: player }, Health { current: 3, maximum: 3 }, )).id(); let player = app.world.spawn(( Player {}, CommandZone { commanders: vec![] }, )).id(); // Create attacking creature let attacker = app.world.spawn(( Creature { power: 5, toughness: 5 }, Attacking { defending: player }, )).id(); // Make commander block app.world.entity_mut(commander).insert( Blocking { blocked_attackers: vec![attacker] } ); // Process combat damage app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage); app.update(); // Check state-based actions - commander should die from damage app.update(); // Player should be prompted to send commander to command zone // We'll simulate them choosing to do so app.world.spawn( ZoneChangeRequest { entity: commander, from: Zone::Battlefield, to: Zone::CommandZone, reason: ZoneChangeReason::Death, } ); // Process zone change app.update(); // Verify commander is in command zone assert!(!app.world.entity(commander).contains::<Health>()); assert!(!app.world.entity(commander).contains::<Blocking>()); let command_zone = app.world.get::<CommandZone>(player).unwrap(); assert!(command_zone.commanders.contains(&commander)); } }
Test: Combat with Commander from Command Zone
#![allow(unused)] fn main() { #[test] fn test_cast_commander_from_command_zone() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (cast_from_command_zone_system, declare_attackers_system)); // Create player let player = app.world.spawn(( Player {}, Mana { white: 5, blue: 5, black: 5, red: 5, green: 5, colorless: 5, }, CommandZone { commanders: vec![commander] }, )).id(); // Create commander in command zone let commander = app.world.spawn(( Commander { owner: player }, CreatureCard { controller: player }, ManaCost { white: 0, blue: 0, black: 0, red: 0, green: 0, colorless: 4, additional_cost: 0, // No command tax yet }, InZone { zone: Zone::CommandZone }, )).id(); // Cast commander from command zone app.world.spawn( CastRequest { card: commander, controller: player, from_zone: Zone::CommandZone, } ); // Process casting app.update(); // Commander should now be on battlefield assert!(app.world.entity(commander).contains::<Creature>()); assert_eq!(app.world.get::<InZone>(commander).unwrap().zone, Zone::Battlefield); // Verify mana was spent let mana_after = app.world.get::<Mana>(player).unwrap(); assert_eq!(mana_after.colorless, 1); // 5 - 4 = 1 // Declare commander as attacker app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::DeclareAttackers); app.world.resource_mut::<TurnManager>().active_player = player; app.world.resource_mut::<AttackDeclaration>().declare_attacker(commander, Entity::PLACEHOLDER); // Process declare attackers app.update(); // Verify commander is attacking assert!(app.world.entity(commander).contains::<Attacking>()); } }
Test Case: Commander Damage Edge Cases
Test: Commander Damage After Death and Recast
#![allow(unused)] fn main() { #[test] fn test_commander_damage_after_recast() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (combat_damage_system, zone_change_system, cast_from_command_zone_system)); // Create player let target_player = app.world.spawn(( Player {}, Health { current: 40, maximum: 40 }, CommanderDamage::default(), )).id(); // Create commander owner let commander_owner = app.world.spawn(( Player {}, Mana { white: 10, blue: 10, black: 10, red: 10, green: 10, colorless: 10, }, CommandZone { commanders: vec![] }, )).id(); // Create commander let commander = app.world.spawn(( Creature { power: 5, toughness: 5 }, Commander { owner: commander_owner }, CreatureCard { controller: commander_owner }, InZone { zone: Zone::Battlefield }, ManaCost { white: 0, blue: 0, black: 0, red: 0, green: 0, colorless: 5, additional_cost: 0, }, )).id(); // Deal commander damage app.world.entity_mut(commander).insert(Attacking { defending: target_player }); app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage); app.update(); // Verify initial commander damage let cmd_damage1 = app.world.get::<CommanderDamage>(target_player).unwrap(); assert_eq!(cmd_damage1.get_damage(commander), 5); // Commander dies app.world.spawn( ZoneChangeRequest { entity: commander, from: Zone::Battlefield, to: Zone::CommandZone, reason: ZoneChangeReason::Death, } ); // Process zone change app.update(); // Update command zone app.world.entity_mut(commander_owner).insert( CommandZone { commanders: vec![commander] } ); // Update commander in command zone app.world.entity_mut(commander).insert( InZone { zone: Zone::CommandZone } ); // Cast commander from command zone (now with command tax) app.world.entity_mut(commander).insert( ManaCost { white: 0, blue: 0, black: 0, red: 0, green: 0, colorless: 5, additional_cost: 2, // Command tax } ); app.world.spawn( CastRequest { card: commander, controller: commander_owner, from_zone: Zone::CommandZone, } ); // Process casting app.update(); // Commander should be back on battlefield assert_eq!(app.world.get::<InZone>(commander).unwrap().zone, Zone::Battlefield); // Deal more commander damage app.world.entity_mut(commander).insert(Attacking { defending: target_player }); app.update(); // Verify commander damage accumulates even after recasting let cmd_damage2 = app.world.get::<CommanderDamage>(target_player).unwrap(); assert_eq!(cmd_damage2.get_damage(commander), 10); // 5 + 5 = 10 } }
Test: Commander Damage Through Redirect Effects
#![allow(unused)] fn main() { #[test] fn test_commander_damage_with_redirect() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (combat_damage_system, replacement_effects_system)); // Create players let player1 = app.world.spawn(( Player {}, Health { current: 40, maximum: 40 }, CommanderDamage::default(), )).id(); let player2 = app.world.spawn(( Player {}, Health { current: 40, maximum: 40 }, CommanderDamage::default(), DamageReplacementEffect { effect_type: ReplacementType::Redirect { percentage: 100, target: TargetType::Player(player3), round_up: true, } }, )).id(); let player3 = app.world.spawn(( Player {}, Health { current: 40, maximum: 40 }, CommanderDamage::default(), )).id(); // Create commander let commander = app.world.spawn(( Creature { power: 5, toughness: 5 }, Commander { owner: player1 }, CreatureCard { controller: player1 }, Attacking { defending: player2 }, )).id(); // Process combat damage app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage); app.update(); // Verify damage was redirected let health2 = app.world.get::<Health>(player2).unwrap(); assert_eq!(health2.current, 40); // No damage taken let health3 = app.world.get::<Health>(player3).unwrap(); assert_eq!(health3.current, 35); // 40 - 5 = 35 // Verify commander damage is tracked for the final recipient let cmd_damage3 = app.world.get::<CommanderDamage>(player3).unwrap(); assert_eq!(cmd_damage3.get_damage(commander), 5); // Original target should not have commander damage let cmd_damage2 = app.world.get::<CommanderDamage>(player2).unwrap(); assert_eq!(cmd_damage2.get_damage(commander), 0); } }
Performance Considerations for Commander Combat Tests
-
Efficient Commander Damage Tracking: Optimize how commander damage is tracked and accumulated.
-
Zone Change Optimizations: Efficiently handle commander zone changes during and after combat.
-
Multiplayer Combat Performance: Ensure combat involving multiple players remains performant.
Test Coverage Checklist
- Multiple sources of commander damage tracking
- Commander damage from clones/copies
- Partner commanders in combat
- Combat damage triggers from commanders
- Attacking multiple players with a commander
- Goaded commanders
- Commander dying in combat and going to command zone
- Casting commander from command zone for combat
- Commander damage persistence after recasting
- Commander damage with redirection effects
Additional Commander-Specific Edge Cases
- Multiple copies of the same commander (from Clone effects)
- Commanders with alternate combat damage effects (infect, wither)
- Face-down commanders (via Morph or similar effects)
- Commander damage with replacement effects (damage doubling)
- "Voltron" strategies with heavily equipped/enchanted commanders
Complex Combat Edge Cases - Test Documentation
Overview
This document outlines test cases for complex interactions in combat that involve multiple mechanics working together. These scenarios test the robustness of our Commander engine when dealing with intricate card interactions and edge cases.
Test Case: Multiple Triggers During Combat
Test: Death Triggers During Combat
#![allow(unused)] fn main() { #[test] fn test_multiple_death_triggers_during_combat() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (combat_damage_system, trigger_system, state_based_actions_system)); // Create creatures with death triggers let creature1 = app.world.spawn(( Creature { power: 3, toughness: 3 }, Attacking { defending: defender_entity }, Health { current: 3, maximum: 3 }, DiesTrigger { effect: TriggerEffect::DealDamage { amount: 2, target: TargetType::Player(defender_entity), } }, )).id(); let creature2 = app.world.spawn(( Creature { power: 3, toughness: 3 }, Blocking { blocked_attackers: vec![creature1] }, Health { current: 3, maximum: 3 }, DiesTrigger { effect: TriggerEffect::GainLife { amount: 2, target: TargetType::Player(controller_entity), } }, )).id(); let defender_entity = app.world.spawn(( Player {}, Health { current: 20, maximum: 20 }, )).id(); let controller_entity = app.world.spawn(( Player {}, Health { current: 20, maximum: 20 }, )).id(); // Process combat damage app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage); app.update(); // Check state-based actions app.update(); // Process death triggers app.update(); // Verify both creatures died assert!(app.world.get::<Health>(creature1).is_none()); assert!(app.world.get::<Health>(creature2).is_none()); // Verify both death triggers resolved let defender_health = app.world.get::<Health>(defender_entity).unwrap(); assert_eq!(defender_health.current, 18); // 20 - 2 from death trigger let controller_health = app.world.get::<Health>(controller_entity).unwrap(); assert_eq!(controller_health.current, 22); // 20 + 2 from death trigger } }
Test Case: Replacement Effects During Combat
Test: Damage Prevention/Redirection
#![allow(unused)] fn main() { #[test] fn test_damage_replacement_effects() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (combat_damage_system, replacement_effects_system)); // Create attacker and blocker let attacker = app.world.spawn(( Creature { power: 5, toughness: 5 }, Attacking { defending: defender_entity }, )).id(); let blocker = app.world.spawn(( Creature { power: 3, toughness: 3 }, Blocking { blocked_attackers: vec![attacker] }, Health { current: 3, maximum: 3 }, // Replacement effect: Redirect half of all damage to controller (rounded up) DamageReplacementEffect { effect_type: ReplacementType::Redirect { percentage: 50, target: TargetType::Player(controller_entity), round_up: true, } }, )).id(); let defender_entity = app.world.spawn(( Player {}, Health { current: 20, maximum: 20 }, )).id(); let controller_entity = app.world.spawn(( Player {}, Health { current: 20, maximum: 20 }, )).id(); // Process combat damage app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage); app.update(); // Verify damage was redirected let blocker_health = app.world.get::<Health>(blocker).unwrap(); assert_eq!(blocker_health.current, 1); // 3 - (5 - 3) = 1 let controller_health = app.world.get::<Health>(controller_entity).unwrap(); assert_eq!(controller_health.current, 17); // 20 - 3 (redirected damage) = 17 // Verify attacker also took damage let attacker_health = app.world.get::<Health>(attacker).unwrap(); assert_eq!(attacker_health.current, 2); // 5 - 3 = 2 } }
Test Case: Layer-Dependent Effects
Test: Characteristic-Defining Abilities and Pump Spells
#![allow(unused)] fn main() { #[test] fn test_layer_dependent_effects() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (spell_resolution_system, characteristic_layer_system)); // Create creature with CDA (power/toughness equal to cards in hand) let creature = app.world.spawn(( CreatureCard {}, // Base power/toughness are handled in layer 7a CharacteristicDefiningAbility { affects: Characteristic::PowerToughness, calculation: Box::new(|world, entity| { // We'll mock this for the test - cards in hand = 4 let cards_in_hand = 4; (cards_in_hand, cards_in_hand) }), }, )).id(); // Process CDA to establish base power/toughness app.update(); // Verify creature has power/toughness equal to cards in hand let creature_stats = app.world.get::<Creature>(creature).unwrap(); assert_eq!(creature_stats.power, 4); assert_eq!(creature_stats.toughness, 4); // Cast pump spell (+2/+2) - this applies in layer 7c app.world.spawn(( PumpSpell { target: creature, power_bonus: 2, toughness_bonus: 2, duration: SpellDuration::EndOfTurn, }, SpellOnStack {}, )); // Resolve spell app.update(); // Verify creature stats were boosted correctly let boosted_creature = app.world.get::<Creature>(creature).unwrap(); assert_eq!(boosted_creature.power, 6); // 4 + 2 = 6 assert_eq!(boosted_creature.toughness, 6); // 4 + 2 = 6 // Discard a card - affects layer 7a CDA app.world.spawn(DiscardCardCommand { player: Entity::PLACEHOLDER, count: 1, }); app.update(); // Verify stats update correctly (CDA now sees 3 cards, then +2/+2 applies) let updated_creature = app.world.get::<Creature>(creature).unwrap(); assert_eq!(updated_creature.power, 5); // 3 + 2 = 5 assert_eq!(updated_creature.toughness, 5); // 3 + 2 = 5 } }
Test Case: Changing Controllers During Combat
Test: Creature Changes Controller During Combat
#![allow(unused)] fn main() { #[test] fn test_creature_changes_controller_during_combat() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (combat_damage_system, spell_resolution_system, controller_system)); let player1 = app.world.spawn(Player {}).id(); let player2 = app.world.spawn(Player {}).id(); // Create attacking creature controlled by player 1 let attacker = app.world.spawn(( Creature { power: 3, toughness: 3 }, Attacking { defending: player2 }, CreatureCard { controller: player1 }, )).id(); // Cast control-changing spell before damage app.world.spawn(( ControlChangeSpell { target: attacker, new_controller: player2, duration: SpellDuration::EndOfTurn, }, SpellOnStack {}, )); // Resolve spell app.update(); // Verify creature changed controllers let creature_card = app.world.get::<CreatureCard>(attacker).unwrap(); assert_eq!(creature_card.controller, player2); // Process combat damage app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage); app.update(); // Verify creature is still attacking the same player assert!(app.world.get::<Attacking>(attacker).is_some()); assert_eq!(app.world.get::<Attacking>(attacker).unwrap().defending, player2); // Verify no damage was dealt (can't attack yourself) let player2_health = app.world.get::<Health>(player2).unwrap(); assert_eq!(player2_health.current, player2_health.maximum); } }
Test Case: Triggered Abilities Affecting Combat
Test: Attack Triggers Modifying Other Creatures
#![allow(unused)] fn main() { #[test] fn test_attack_trigger_modifying_combat() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (declare_attackers_system, trigger_system, combat_damage_system)); let player1 = app.world.spawn(Player {}).id(); let player2 = app.world.spawn(Player { health: Health { current: 20, maximum: 20 }, }).id(); // Create creature with attack trigger let battle_leader = app.world.spawn(( Creature { power: 2, toughness: 2 }, CreatureCard { controller: player1 }, AttackTrigger { effect: TriggerEffect::PumpCreatures { target_filter: TargetFilter::Controlled { controller: player1 }, power_bonus: 1, toughness_bonus: 1, duration: SpellDuration::EndOfTurn, } }, )).id(); // Create another creature that will benefit from the trigger let other_attacker = app.world.spawn(( Creature { power: 2, toughness: 2 }, CreatureCard { controller: player1 }, )).id(); // Declare attackers app.world.entity_mut(battle_leader).insert(Attacking { defending: player2 }); app.world.entity_mut(other_attacker).insert(Attacking { defending: player2 }); // Process attack triggers app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::DeclareAttackers); app.update(); // Verify both creatures were boosted let battle_leader_stats = app.world.get::<Creature>(battle_leader).unwrap(); assert_eq!(battle_leader_stats.power, 3); // 2 + 1 = 3 let other_attacker_stats = app.world.get::<Creature>(other_attacker).unwrap(); assert_eq!(other_attacker_stats.power, 3); // 2 + 1 = 3 // Process combat damage app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage); app.update(); // Verify damage with the boost let player2_health = app.world.get::<Health>(player2).unwrap(); assert_eq!(player2_health.current, 14); // 20 - 3 - 3 = 14 } }
Test Case: Damage Doubling and Prevention Interactions
Test: Damage Doubling with Prevention Shield
#![allow(unused)] fn main() { #[test] fn test_damage_doubling_with_prevention() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (combat_damage_system, replacement_effects_system)); // Create attacker with damage doubling effect let attacker = app.world.spawn(( Creature { power: 4, toughness: 4 }, Attacking { defending: defender_entity }, DamageReplacementEffect { effect_type: ReplacementType::Double { condition: DamageCondition::DealingCombatDamage, } }, )).id(); // Create defender with protection shield let defender_entity = app.world.spawn(( Player {}, Health { current: 20, maximum: 20 }, PreventDamage { amount: 5, source_filter: None, }, )).id(); // Process combat damage app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage); app.update(); // The original 4 damage is doubled to 8, then 5 is prevented let defender_health = app.world.get::<Health>(defender_entity).unwrap(); assert_eq!(defender_health.current, 17); // 20 - (8 - 5) = 17 } }
Test Case: Indestructible in Combat
Test: Indestructible Creature with Lethal Damage
#![allow(unused)] fn main() { #[test] fn test_indestructible_with_lethal_damage() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (combat_damage_system, state_based_actions_system)); // Create indestructible attacker let indestructible_attacker = app.world.spawn(( Creature { power: 4, toughness: 4 }, Attacking { defending: defender_entity }, Health { current: 4, maximum: 4 }, Indestructible {}, )).id(); // Create blocker with deathtouch let deathtouch_blocker = app.world.spawn(( Creature { power: 1, toughness: 1 }, Blocking { blocked_attackers: vec![indestructible_attacker] }, Health { current: 1, maximum: 1 }, Deathtouch {}, )).id(); let defender_entity = app.world.spawn(( Player {}, Health { current: 20, maximum: 20 }, )).id(); // Process combat damage app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage); app.update(); // Check state-based actions app.update(); // Verify blocker died assert!(app.world.get::<Health>(deathtouch_blocker).is_none()); // Verify indestructible attacker is damaged but alive let attacker_health = app.world.get::<Health>(indestructible_attacker).unwrap(); assert_eq!(attacker_health.current, 3); // 4 - 1 = 3 assert!(app.world.entity(indestructible_attacker).contains::<Indestructible>()); // Verify it can still be in further combats despite lethal deathtouch damage let attacker_health = app.world.get::<Health>(indestructible_attacker).unwrap(); app.world.entity_mut(indestructible_attacker).insert( DamageReceived { amount: 10, source: deathtouch_blocker, is_combat_damage: true, } ); // Process more damage app.update(); // Check state-based actions app.update(); // Verify indestructible creature is still alive despite lethal damage assert!(app.world.get::<Health>(indestructible_attacker).is_some()); } }
Test Case: Phasing and Pump Spell Interactions
Test: Phasing Out After Pump Spell
#![allow(unused)] fn main() { #[test] fn test_phasing_after_pump_spell() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (spell_resolution_system, phasing_system, end_of_turn_system)); // Create creature let creature = app.world.spawn(( Creature { power: 2, toughness: 2 }, CreatureCard {}, )).id(); // Cast pump spell app.world.spawn(( PumpSpell { target: creature, power_bonus: 2, toughness_bonus: 2, duration: SpellDuration::EndOfTurn, }, SpellOnStack {}, )); // Resolve spell app.update(); // Verify creature stats were boosted let boosted_creature = app.world.get::<Creature>(creature).unwrap(); assert_eq!(boosted_creature.power, 4); assert_eq!(boosted_creature.toughness, 4); // Phase out the creature app.world.entity_mut(creature).insert(PhasedOut {}); // Process end of turn app.world.resource_mut::<TurnManager>().current_phase = Phase::End(EndPhaseStep::End); app.update(); // The creature is phased out, so end of turn effects shouldn't affect it // Phase the creature back in app.world.entity_mut(creature).remove::<PhasedOut>(); // Now the pump effect should still be active (since it didn't wear off while phased out) let still_boosted = app.world.get::<Creature>(creature).unwrap(); assert_eq!(still_boosted.power, 4); assert_eq!(still_boosted.toughness, 4); // Process another end of turn app.update(); // Now the effect should expire let end_of_turn = app.world.get::<Creature>(creature).unwrap(); assert_eq!(end_of_turn.power, 2); assert_eq!(end_of_turn.toughness, 2); } }
Performance Considerations for Edge Case Tests
-
Efficient Interaction Processing: Ensure complex interaction processing is optimized.
-
Minimize Redundant Query Operations: Structure queries to minimize redundant operations.
-
Layer System Optimization: Efficiently process layer-dependent effects in the correct order.
Test Coverage Checklist
- Multiple death triggers during combat
- Damage replacement/redirection effects
- Layer-dependent ability interactions
- Controller change during combat
- Attack triggers modifying other creatures
- Damage doubling with prevention
- Indestructible creatures with lethal damage
- Phasing and pump spell interactions
Additional Considerations
- Tests should verify both immediate effects and downstream consequences
- Multiple mechanics should be tested in combination to ensure correct interactions
- Order-dependent effects should be tested with different sequencing
- Extreme edge cases should be included to stress-test the system
Combat Phasing Tests
This section contains tests for phasing effects during combat in the Commander format.
Tests
Phasing During Combat
Tests for how phasing effects interact with various stages of combat, including:
- Attackers phasing out after being declared
- Blockers phasing out after being declared
- Permanents with combat-relevant abilities phasing out
- Phasing effects on auras and equipment attached to creatures in combat
- Phasing and combat damage
- Phasing interaction with combat triggers
These tests ensure that the complex rules interactions between phasing and combat are handled correctly within the game engine.
Phasing During Combat - Test Cases
Overview
Phasing is a complex mechanic where permanents temporarily leave and re-enter the game without triggering "leaves the battlefield" or "enters the battlefield" effects. When a creature phases out during combat, several edge cases can occur that require special handling.
This document outlines test cases for phasing interactions during combat in our Commander engine.
Test Case: Attacker Phases Out
Test: Attacking Creature Phases Out During Declare Attackers
#![allow(unused)] fn main() { #[test] fn test_attacker_phases_out_after_declare_attackers() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (declare_attackers_system, phasing_system)); // Create attacker and defending player let attacker = app.world.spawn(( Creature { power: 3, toughness: 3, }, Attacking { defending: defender_entity }, )).id(); let defender_entity = app.world.spawn(( Player {}, Health { current: 20, maximum: 20 }, )).id(); // Phase out the attacker app.world.spawn(PhaseOutCommand { target: attacker }); // Process phasing app.update(); // Verify the creature is phased out but still marked as attacking assert!(app.world.get::<PhasedOut>(attacker).is_some()); assert!(app.world.get::<Attacking>(attacker).is_some()); // When combat damage is processed, it should not deal damage app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage); app.update(); // Verify no damage was dealt to defender let health = app.world.get::<Health>(defender_entity).unwrap(); assert_eq!(health.current, 20); } }
Test: Phased Out Attacker at End of Combat
#![allow(unused)] fn main() { #[test] fn test_phased_out_attacker_at_end_of_combat() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (end_of_combat_system, phasing_system)); // Create phased out attacker let attacker = app.world.spawn(( Creature { power: 3, toughness: 3, }, Attacking { defending: defender_entity }, PhasedOut {}, )).id(); // Process end of combat app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::End); app.world.resource_mut::<CombatSystem>().end_of_combat.triggers_processed = true; app.update(); // Verify attacking status is removed, even though creature is phased out assert!(!app.world.entity(attacker).contains::<Attacking>()); } }
Test Case: Blocker Phases Out
Test: Blocking Creature Phases Out During Declare Blockers
#![allow(unused)] fn main() { #[test] fn test_blocker_phases_out_after_declare_blockers() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (declare_blockers_system, phasing_system, combat_damage_system)); // Create attacker, blocker, and player let attacker = app.world.spawn(( Creature { power: 3, toughness: 3, }, Attacking { defending: defender_entity }, )).id(); let blocker = app.world.spawn(( Creature { power: 2, toughness: 2, }, Blocking { blocked_attackers: vec![attacker] }, )).id(); let defender_entity = app.world.spawn(( Player {}, Health { current: 20, maximum: 20 }, )).id(); // Phase out the blocker app.world.spawn(PhaseOutCommand { target: blocker }); // Process phasing app.update(); // Verify the creature is phased out but still marked as blocking assert!(app.world.get::<PhasedOut>(blocker).is_some()); assert!(app.world.get::<Blocking>(blocker).is_some()); // When combat damage is processed, attacker should deal damage to player // since phased out blocker cannot block app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage); app.update(); // Verify damage was dealt to defender (attacker was effectively unblocked) let health = app.world.get::<Health>(defender_entity).unwrap(); assert_eq!(health.current, 17); } }
Test Case: Phasing During Combat Damage
Test: Creature Phases Out in Response to Damage
#![allow(unused)] fn main() { #[test] fn test_creature_phases_out_in_response_to_damage() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (combat_damage_system, phasing_system)); // Create attacker and blocker let attacker = app.world.spawn(( Creature { power: 3, toughness: 3, }, Attacking { defending: defender_entity }, )).id(); let blocker = app.world.spawn(( Creature { power: 2, toughness: 2, }, Blocking { blocked_attackers: vec![attacker] }, Health { current: 2, maximum: 2 }, )).id(); // Simulate player responding to damage by phasing out the blocker // (in a real implementation, this would be triggered by priority passing) app.world.spawn(PhaseOutCommand { target: blocker, trigger_condition: TriggerCondition::BeforeDamage, }); // Process combat damage app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage); app.update(); // Verify blocker phased out and did not receive damage assert!(app.world.get::<PhasedOut>(blocker).is_some()); let health = app.world.get::<Health>(blocker).unwrap(); assert_eq!(health.current, 2); // Health unchanged // Verify attacker did not receive damage either let attacker_health = app.world.get::<Health>(attacker).unwrap(); assert_eq!(attacker_health.current, attacker_health.maximum); } }
Test Case: Phasing In During Combat
Test: Creature Phases In During Combat
#![allow(unused)] fn main() { #[test] fn test_creature_phases_in_during_combat() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (phasing_system, combat_system)); // Create phased out creature let creature = app.world.spawn(( Creature { power: 3, toughness: 3, }, PhasedOut {}, )).id(); // Set up phase in trigger for declare blockers step app.world.spawn(PhaseInCommand { target: creature, trigger_phase: Phase::Combat(CombatStep::DeclareBlockers), }); // Advance to declare blockers app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::DeclareBlockers); app.update(); // Verify creature phased in assert!(app.world.get::<PhasedOut>(creature).is_none()); // Verify creature cannot be declared as a blocker this combat // (creatures that phase in are treated as though they just entered the battlefield) let can_block = app.world.get_resource::<CombatSystem>().unwrap() .can_block(creature, attacker_entity); assert!(!can_block); } }
Test Case: Phasing with Auras and Equipment
Test: Equipped Creature Phases Out
#![allow(unused)] fn main() { #[test] fn test_equipped_creature_phases_out_during_combat() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (phasing_system, combat_damage_system)); // Create creature with equipment let creature = app.world.spawn(( Creature { power: 2, toughness: 2, }, Attacking { defending: defender_entity }, )).id(); let equipment = app.world.spawn(( Equipment { equipped_to: creature, power_bonus: 2, toughness_bonus: 0, }, Attached { attached_to: creature }, )).id(); // Phase out the creature app.world.spawn(PhaseOutCommand { target: creature }); app.update(); // Verify creature and equipment are both phased out assert!(app.world.get::<PhasedOut>(creature).is_some()); assert!(app.world.get::<PhasedOut>(equipment).is_some()); // Process combat damage app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage); app.update(); // Verify no damage was dealt let health = app.world.get::<Health>(defender_entity).unwrap(); assert_eq!(health.current, 20); // Phase in during a later turn app.world.spawn(PhaseInCommand { target: creature, trigger_phase: Phase::Main(MainPhaseStep::First), }); app.world.resource_mut::<TurnManager>().current_phase = Phase::Main(MainPhaseStep::First); app.update(); // Verify both creature and equipment phased in and are still attached assert!(app.world.get::<PhasedOut>(creature).is_none()); assert!(app.world.get::<PhasedOut>(equipment).is_none()); assert_eq!(app.world.get::<Equipment>(equipment).unwrap().equipped_to, creature); assert_eq!(app.world.get::<Attached>(equipment).unwrap().attached_to, creature); } }
Integration with Turn Structure
Test: Phasing and Turn Phases
#![allow(unused)] fn main() { #[test] fn test_phasing_respects_turn_structure() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (phasing_system, turn_system)); // Create creature that phases out let creature = app.world.spawn(( Creature { power: 3, toughness: 3, }, )).id(); // Phase out every other turn app.world.spawn(RecurringPhaseCommand { target: creature, phase_out_on: TurnCondition::ActivePlayerTurn, phase_in_on: TurnCondition::NonActivePlayerTurn, }); // Set active player app.world.resource_mut::<TurnManager>().active_player = player1_entity; app.update(); // Verify creature is phased out on active player's turn assert!(app.world.get::<PhasedOut>(creature).is_some()); // Change active player app.world.resource_mut::<TurnManager>().active_player = player2_entity; app.update(); // Verify creature phases in on non-active player's turn assert!(app.world.get::<PhasedOut>(creature).is_none()); } }
Performance Considerations for Phasing Tests
-
Batch Phasing Operations: Group phasing operations to minimize entity access.
-
Efficient Phase Tracking: Use a more efficient system for tracking phased entities.
-
Minimize Query Iterations: Structure queries to minimize iterations through phased entities.
Test Coverage Checklist
- Attackers phasing out during declare attackers
- Attackers phasing out during combat damage
- Blockers phasing out during declare blockers
- Blockers phasing out during combat damage
- Creatures phasing in during combat
- End of combat cleanup with phased entities
- Phasing with attached permanents (equipment, auras)
- Turn structure integration with phasing
Additional Edge Cases to Consider
- Multiple creatures phasing in/out simultaneously
- Phasing commander in and out (commander damage tracking)
- Phasing and "enters the battlefield" replacement effects
- Phasing and state-based actions
Combat Pump Spell Tests
This section contains tests for power and toughness modifying effects (pump spells) during combat in the Commander format.
Tests
Combat Pumps
Tests for how pump spells and similar effects interact with combat, including:
- Instant-speed pump spells after blockers are declared
- Pump effects that grant additional abilities (like first strike)
- Timing of pump effects and their impact on combat damage
- Interaction between multiple pump effects
- Pump effects that last until end of turn versus those with permanent effects
- Combat tricks that affect multiple creatures simultaneously
These tests ensure that power/toughness modification during combat is handled correctly according to the rules and timing of Magic: The Gathering.
Combat Pump Spells - Test Cases
Overview
Combat pump spells are instants that temporarily boost a creature's power and/or toughness, often used during combat to alter the outcome. These spells can be cast at various points during the combat phase, leading to different results depending on timing.
This document outlines test cases for combat pump spell interactions in our Commander engine.
Test Case: Basic Pump Spell Effects
Test: Power/Toughness Boost During Declare Blockers
#![allow(unused)] fn main() { #[test] fn test_pump_spell_during_declare_blockers() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (declare_blockers_system, spell_resolution_system)); // Create attacker and potential blocker let attacker = app.world.spawn(( Creature { power: 4, toughness: 4 }, Attacking { defending: defender_entity }, )).id(); let small_blocker = app.world.spawn(( Creature { power: 1, toughness: 1 }, CreatureCard { controller: defender_entity }, )).id(); let defender_entity = app.world.spawn(( Player {}, Health { current: 20, maximum: 20 }, )).id(); // Set up initially - blocker cannot block due to being too small app.world.resource_mut::<CombatSystem>().can_block_check = |blocker, attacker| { let blocker_creature = app.world.get::<Creature>(blocker).unwrap(); let attacker_creature = app.world.get::<Creature>(attacker).unwrap(); blocker_creature.power >= attacker_creature.power / 2 }; // Can't block initially let can_block_initially = app.world.resource::<CombatSystem>() .can_block(small_blocker, attacker); assert!(!can_block_initially); // Cast pump spell (+3/+3 until end of turn) app.world.spawn(( PumpSpell { target: small_blocker, power_bonus: 3, toughness_bonus: 3, duration: SpellDuration::EndOfTurn, }, SpellOnStack {}, )); // Resolve spell app.update(); // Verify creature stats were boosted let boosted_creature = app.world.get::<Creature>(small_blocker).unwrap(); assert_eq!(boosted_creature.power, 4); assert_eq!(boosted_creature.toughness, 4); // Now should be able to block let can_block_after = app.world.resource::<CombatSystem>() .can_block(small_blocker, attacker); assert!(can_block_after); } }
Test Case: Pump Spells During Combat Damage
Test: Pump Spell Saving Creature from Lethal Damage
#![allow(unused)] fn main() { #[test] fn test_pump_spell_prevents_lethal_damage() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (combat_damage_system, spell_resolution_system)); // Create attacker and blocker let attacker = app.world.spawn(( Creature { power: 3, toughness: 3 }, Attacking { defending: defender_entity }, )).id(); let blocker = app.world.spawn(( Creature { power: 2, toughness: 2 }, Blocking { blocked_attackers: vec![attacker] }, Health { current: 2, maximum: 2 }, )).id(); // Before damage is dealt, cast pump spell to increase toughness // In a real implementation, this would be during the priority window app.world.spawn(( PumpSpell { target: blocker, power_bonus: 0, toughness_bonus: 2, duration: SpellDuration::EndOfTurn, }, SpellOnStack {}, )); // Resolve spell app.update(); // Verify creature stats were boosted let boosted_creature = app.world.get::<Creature>(blocker).unwrap(); assert_eq!(boosted_creature.power, 2); assert_eq!(boosted_creature.toughness, 4); // Process combat damage app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage); app.update(); // Verify blocker survived let health = app.world.get::<Health>(blocker).unwrap(); assert!(health.current > 0); } }
Test: Increasing Power After Blockers to Deal More Damage
#![allow(unused)] fn main() { #[test] fn test_pump_spell_increases_damage_after_blockers() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (combat_damage_system, spell_resolution_system)); // Create attacker and defending player let attacker = app.world.spawn(( Creature { power: 2, toughness: 2 }, Attacking { defending: defender_entity }, )).id(); let defender_entity = app.world.spawn(( Player {}, Health { current: 20, maximum: 20 }, )).id(); // After declare blockers, cast pump spell to increase power app.world.spawn(( PumpSpell { target: attacker, power_bonus: 3, toughness_bonus: 0, duration: SpellDuration::EndOfTurn, }, SpellOnStack {}, )); // Resolve spell app.update(); // Verify creature stats were boosted let boosted_creature = app.world.get::<Creature>(attacker).unwrap(); assert_eq!(boosted_creature.power, 5); // Process combat damage app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage); app.update(); // Verify more damage was dealt to player let health = app.world.get::<Health>(defender_entity).unwrap(); assert_eq!(health.current, 15); // 20 - 5 = 15 } }
Test Case: Timing Edge Cases
Test: Pump Spell During First Strike Damage
#![allow(unused)] fn main() { #[test] fn test_pump_spell_between_first_strike_and_normal_damage() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (first_strike_damage_system, combat_damage_system, spell_resolution_system)); // Create first strike attacker and regular blocker let first_strike_attacker = app.world.spawn(( Creature { power: 2, toughness: 2 }, Attacking { defending: defender_entity }, FirstStrike {}, )).id(); let regular_blocker = app.world.spawn(( Creature { power: 2, toughness: 2 }, Blocking { blocked_attackers: vec![first_strike_attacker] }, Health { current: 2, maximum: 2 }, )).id(); // Process first strike damage app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::FirstStrike); app.update(); // Verify blocker took damage but is still alive let health_after_first_strike = app.world.get::<Health>(regular_blocker).unwrap(); assert_eq!(health_after_first_strike.current, 0); // SBA would normally destroy the creature here, but we'll simulate a pump spell in response // Cast Sudden Invigoration (instant that gives +0/+2 and prevents damage this turn) app.world.spawn(( PumpSpell { target: regular_blocker, power_bonus: 0, toughness_bonus: 2, duration: SpellDuration::EndOfTurn, }, SpellOnStack {}, PreventDamageEffect { amount: 2 }, )); // Resolve spell and apply effects app.update(); // Reset health as if the damage had been prevented app.world.entity_mut(regular_blocker).insert(Health { current: 2, maximum: 2 }); // Verify creature stats were boosted and creature is alive let boosted_creature = app.world.get::<Creature>(regular_blocker).unwrap(); assert_eq!(boosted_creature.toughness, 4); let health_after_spell = app.world.get::<Health>(regular_blocker).unwrap(); assert_eq!(health_after_spell.current, 2); // Process regular combat damage app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage); app.update(); // Verify blocker survived regular combat damage too let final_health = app.world.get::<Health>(regular_blocker).unwrap(); assert_eq!(final_health.current, 2); // Damage was prevented } }
Test: Pump Spell at End of Combat
#![allow(unused)] fn main() { #[test] fn test_pump_spell_at_end_of_combat() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (end_of_combat_system, spell_resolution_system)); // Create creature let creature = app.world.spawn(( Creature { power: 2, toughness: 2 }, CreatureCard {}, )).id(); // Cast pump spell with "until end of turn" duration app.world.spawn(( PumpSpell { target: creature, power_bonus: 3, toughness_bonus: 3, duration: SpellDuration::EndOfTurn, }, SpellOnStack {}, )); // Resolve spell app.update(); // Verify creature stats were boosted let boosted_creature = app.world.get::<Creature>(creature).unwrap(); assert_eq!(boosted_creature.power, 5); assert_eq!(boosted_creature.toughness, 5); // Add EndOfTurn effect app.world.entity_mut(creature).insert(UntilEndOfTurn { original_power: 2, original_toughness: 2, }); // Process end of combat phase app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::End); app.world.resource_mut::<CombatSystem>().end_of_combat.triggers_processed = true; app.update(); // Verify creature stats still boosted after end of combat // (effects last until end of turn, not end of combat) let still_boosted = app.world.get::<Creature>(creature).unwrap(); assert_eq!(still_boosted.power, 5); assert_eq!(still_boosted.toughness, 5); // Process end of turn app.world.resource_mut::<TurnManager>().current_phase = Phase::End(EndPhaseStep::End); app.update(); // Now the effects should wear off let end_of_turn = app.world.get::<Creature>(creature).unwrap(); assert_eq!(end_of_turn.power, 2); assert_eq!(end_of_turn.toughness, 2); } }
Test Case: Multiple Pump Spells
Test: Stacking Pump Effects
#![allow(unused)] fn main() { #[test] fn test_stacking_pump_spells() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, spell_resolution_system); // Create creature let creature = app.world.spawn(( Creature { power: 1, toughness: 1 }, CreatureCard {}, )).id(); // Cast first pump spell app.world.spawn(( PumpSpell { target: creature, power_bonus: 2, toughness_bonus: 2, duration: SpellDuration::EndOfTurn, }, SpellOnStack {}, )); // Resolve first spell app.update(); // Cast second pump spell app.world.spawn(( PumpSpell { target: creature, power_bonus: 1, toughness_bonus: 3, duration: SpellDuration::EndOfTurn, }, SpellOnStack {}, )); // Resolve second spell app.update(); // Verify creature stats were properly stacked let boosted_creature = app.world.get::<Creature>(creature).unwrap(); assert_eq!(boosted_creature.power, 4); // 1 + 2 + 1 = 4 assert_eq!(boosted_creature.toughness, 6); // 1 + 2 + 3 = 6 } }
Test Case: Pump Spells and Combat Abilities
Test: Pump Giving Trample
#![allow(unused)] fn main() { #[test] fn test_pump_spell_gives_trample() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (combat_damage_system, spell_resolution_system)); // Create attacker, blocker, and player let attacker = app.world.spawn(( Creature { power: 4, toughness: 4 }, Attacking { defending: defender_entity }, )).id(); let blocker = app.world.spawn(( Creature { power: 2, toughness: 2 }, Blocking { blocked_attackers: vec![attacker] }, Health { current: 2, maximum: 2 }, )).id(); let defender_entity = app.world.spawn(( Player {}, Health { current: 20, maximum: 20 }, )).id(); // Cast pump spell that also grants trample app.world.spawn(( PumpSpell { target: attacker, power_bonus: 1, toughness_bonus: 1, duration: SpellDuration::EndOfTurn, }, SpellOnStack {}, GrantAbilityEffect { ability: CreatureAbility::Trample, duration: SpellDuration::EndOfTurn, }, )); // Resolve spell app.update(); // Verify creature stats were boosted and gained trample let boosted_creature = app.world.get::<Creature>(attacker).unwrap(); assert_eq!(boosted_creature.power, 5); assert!(app.world.get::<Trample>(attacker).is_some()); // Process combat damage app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::CombatDamage); app.update(); // Verify blocker is destroyed let blocker_health = app.world.get::<Health>(blocker).unwrap(); assert_eq!(blocker_health.current, 0); // Verify excess damage trampled through to player let player_health = app.world.get::<Health>(defender_entity).unwrap(); assert_eq!(player_health.current, 17); // 20 - (5-2) = 17 } }
Test Case: Pump Spell Targeting
Test: Incorrectly Targeted Pump Spell
#![allow(unused)] fn main() { #[test] fn test_illegally_targeted_pump_spell() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, spell_resolution_system); // Create creature with hexproof let hexproof_creature = app.world.spawn(( Creature { power: 2, toughness: 2 }, CreatureCard {}, Hexproof {}, )).id(); // Create opposing player let opponent_entity = app.world.spawn(Player {}).id(); // Cast pump spell from opponent (should fail due to hexproof) app.world.spawn(( PumpSpell { target: hexproof_creature, power_bonus: 2, toughness_bonus: 2, duration: SpellDuration::EndOfTurn, }, SpellOnStack {}, SpellController { controller: opponent_entity }, )); // Attempt to resolve spell (should be countered by game rules) app.update(); // Verify creature stats were not changed let creature = app.world.get::<Creature>(hexproof_creature).unwrap(); assert_eq!(creature.power, 2); assert_eq!(creature.toughness, 2); // Verify spell was countered let spell_events = app.world.resource::<Events<SpellEvent>>().get_reader().iter().collect::<Vec<_>>(); assert!(spell_events.iter().any(|event| matches!(event, SpellEvent::Countered(_)))); } }
Integration with Combat System
Test: Pump Spells and State-Based Actions
#![allow(unused)] fn main() { #[test] fn test_pump_spell_and_state_based_actions() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (spell_resolution_system, state_based_actions_system)); // Create damaged creature let creature = app.world.spawn(( Creature { power: 2, toughness: 2 }, CreatureCard {}, Health { current: 1, maximum: 2 }, )).id(); // Deal damage to creature app.world.entity_mut(creature).insert(DamageReceived { amount: 2, source: Entity::PLACEHOLDER, is_combat_damage: false, }); // Process state-based actions to apply damage app.update(); // Health would be 0, but don't destroy yet // Cast pump spell to increase toughness before SBA check app.world.spawn(( PumpSpell { target: creature, power_bonus: 0, toughness_bonus: 2, duration: SpellDuration::EndOfTurn, }, SpellOnStack {}, InstantEffect {}, )); // Resolve spell app.update(); // Verify creature stats were boosted let boosted_creature = app.world.get::<Creature>(creature).unwrap(); assert_eq!(boosted_creature.toughness, 4); // Update health to reflect new toughness (would happen in a real implementation) app.world.entity_mut(creature).insert(Health { current: 1, // Still has damage marked maximum: 4, }); // Verify creature is still alive despite the damage assert!(app.world.get::<Health>(creature).is_some()); // Process state-based actions again app.update(); // Verify creature is still alive assert!(app.world.get::<Health>(creature).is_some()); } }
Performance Considerations for Pump Spell Tests
-
Optimize Spell Resolution: Structure spell resolution to minimize entity access.
-
Batch Effect Application: Group similar effects when applying multiple pump spells.
-
Efficient Stat Calculation: Use an efficient system for calculating final creature stats.
Test Coverage Checklist
- Basic pump spell application
- Pump spells preventing lethal damage
- Increasing power after blockers
- Pump spells during first strike damage
- Pump spells at end of combat
- Stacking multiple pump spells
- Pump spells granting abilities
- Invalid pump spell targeting
- Pump spells and state-based actions
- Pump spells with delayed effects
Additional Edge Cases to Consider
- Pump spells with conditional effects
- Pump spells that scale based on game state
- Pump spells that trigger other abilities
- Temporary control change plus pump effects
- Layer-dependent pump effects (timestamps, dependencies)
Special Rules
This section covers special rules and mechanics unique to the Commander format.
Contents
- Multiplayer Politics - Voting, deals, and social mechanics
- Partner Commanders - Commander partners and related mechanics
- Commander Ninjutsu - The Commander Ninjutsu mechanic
- Commander Death Triggers - How commander death and zone changes work
- Commander-Specific Cards - Cards designed specifically for Commander
Commander-Specific Rules
The special rules section covers Commander-specific rules that are unique to the format:
Partners and Backgrounds
Partner mechanics allow players to have two commanders. There are several forms:
- Universal Partners: Any two commanders with "Partner" can be paired
- Partner With: Specific cards that can only partner with their named counterpart
- Background: Legendary creatures that "can have a Background" as a second commander
- Friends Forever: A variant of Partner that allows pairing any two "Friends Forever" cards
See Partner Commanders for implementation details.
Commander Ninjutsu
Commander Ninjutsu is a variant of the Ninjutsu mechanic that allows a commander to be put onto the battlefield from the command zone by returning an unblocked attacker to hand. This mechanic is currently only found on one card: Yuriko, the Tiger's Shadow.
See Commander Ninjutsu for implementation details.
Commander Death and Zone Changes
In Commander, when a commander would change zones, its owner can choose to move it to the command zone instead. Special rules govern how this interacts with "dies" triggers and other zone-change abilities.
See Commander Death Triggers for implementation details.
Commander-Specific Cards
Many cards have been designed specifically for the Commander format, including:
- Cards that reference the command zone directly
- Cards with mechanics only found in Commander products (Myriad, Lieutenant, etc.)
- Cards that affect the Commander tax
- Cards designed for multiplayer political play
See Commander-Specific Cards for implementation details.
Multiplayer Politics
Commander is often played as a multiplayer format, and certain mechanics are designed for multiplayer interactions:
- Voting mechanics
- Deal-making abilities
- Group effects that affect all players
- Incentives and deterrents for attacking
See Multiplayer Politics and Politics Testing for implementation details.
Related Mechanics
Some Commander-specific mechanics interact with other systems:
- Commander Damage - For tracking commander combat damage
- Command Zone - For commander zone transitions
- Commander Tax - For additional costs to cast commanders
- Color Identity - For deck construction restrictions
Testing Special Rules
The tests directory contains test cases for verifying the correct implementation of special Commander rules and edge case handling.
Multiplayer Politics
Overview
The Multiplayer Politics module handles the social and strategic elements unique to multiplayer Commander games. This includes deal-making, alliance formation, temporary agreements, voting mechanics, and other player interactions not codified in the standard Magic rules.
Core Features
The system includes:
- Deal Making System: Framework for players to propose, accept, and track in-game deals
- Voting Mechanics: Implementation of cards with Council's Dilemma, Will of the Council, and other voting abilities
- Threat Assessment: Tools to analyze and display relative threat levels of players
- Alliance Tracking: Temporary cooperative arrangements between players
- Table Talk Integration: Support for in-game communication with policy enforcement
- Goad Mechanics: Implementation of abilities that force creatures to attack
Implementation
The politics system is implemented through several interconnected components:
#![allow(unused)] fn main() { #[derive(Component)] pub struct PoliticsComponent { // Current deals, alliances, and political state pub active_deals: Vec<Deal>, pub alliances: HashMap<Entity, AllianceStrength>, pub political_capital: f32, pub trust_level: HashMap<Entity, TrustLevel>, // Historical tracking pub broken_deals: Vec<BrokenDeal>, pub past_alliances: Vec<PastAlliance>, } #[derive(Resource)] pub struct PoliticsSystem { // Global politics configuration pub enable_deals: bool, pub allow_secret_deals: bool, pub deal_enforcement_level: DealEnforcementLevel, // Event history pub political_events: VecDeque<PoliticalEvent>, } }
Deal Structure
Deals are structured entities that capture player agreements:
#![allow(unused)] fn main() { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Deal { /// Unique identifier for the deal pub id: Uuid, /// Player who proposed the deal pub proposer: Entity, /// Player(s) who accepted the deal pub acceptors: Vec<Entity>, /// Terms of the deal - what each party agrees to do pub terms: Vec<DealTerm>, /// When the deal expires (if temporary) pub expiration: Option<DealExpiration>, /// Current status of the deal pub status: DealStatus, /// When the deal was created pub created_at: f64, /// When the deal was last updated pub last_updated: f64, } #[derive(Clone, Debug, Serialize, Deserialize)] pub enum DealTerm { /// Promise not to attack a player for N turns NonAggression { target: Entity, duration: u32, }, /// Promise to attack a specific player AttackPlayer { target: Entity, next_turn_only: bool, }, /// Promise not to counter a player's next spell NoCounterspell { target: Entity, duration: u32, }, /// Promise to share resources (e.g. let them draw when you draw) ShareResource { resource_type: ResourceType, target: Entity, amount: u32, }, /// Promise to vote with a player on the next vote VoteAlignment { ally: Entity, vote_count: u32, }, /// Custom term with free-form text Custom { description: String, }, } }
Deal Making
The deal making system allows players to:
- Propose deals with specific terms and duration
- Accept or reject deals from other players
- Set automatic deal conditions and consequences
- Track deal fulfillment and violations
Deals are non-binding at the rules level but provide framework for player agreements.
#![allow(unused)] fn main() { /// System that handles deal proposals pub fn handle_deal_proposals( mut commands: Commands, mut deal_events: EventReader<DealProposalEvent>, mut deal_response_events: EventWriter<DealResponseEvent>, mut politics_components: Query<&mut PoliticsComponent>, ) { for event in deal_events.read() { if let Ok(mut proposer_politics) = politics_components.get_mut(event.proposer) { // Create new deal let deal = Deal { id: Uuid::new_v4(), proposer: event.proposer, acceptors: Vec::new(), terms: event.terms.clone(), expiration: event.expiration.clone(), status: DealStatus::Proposed, created_at: event.timestamp, last_updated: event.timestamp, }; // Add to proposer's active deals proposer_politics.active_deals.push(deal.clone()); // Notify target players for target in &event.targets { deal_response_events.send(DealResponseEvent { deal_id: deal.id, response_type: DealResponseType::Offered, player: *target, timestamp: event.timestamp, }); } } } } /// System that handles deal responses pub fn handle_deal_responses( mut commands: Commands, mut deal_response_events: EventReader<DealResponseEvent>, mut politics_components: Query<&mut PoliticsComponent>, mut deal_update_events: EventWriter<DealUpdateEvent>, ) { for event in deal_response_events.read() { // Handle player responses to deals match event.response_type { DealResponseType::Accept => { // Update the deal status for (entity, mut politics) in politics_components.iter_mut() { for deal in &mut politics.active_deals { if deal.id == event.deal_id { deal.acceptors.push(event.player); deal.last_updated = event.timestamp; // If all targets have accepted, activate the deal if deal.acceptors.len() >= deal.terms.len() { deal.status = DealStatus::Active; deal_update_events.send(DealUpdateEvent { deal_id: deal.id, new_status: DealStatus::Active, timestamp: event.timestamp, }); } break; } } } }, DealResponseType::Reject => { // Handle rejection for (entity, mut politics) in politics_components.iter_mut() { for deal in &mut politics.active_deals { if deal.id == event.deal_id { deal.status = DealStatus::Rejected; deal.last_updated = event.timestamp; deal_update_events.send(DealUpdateEvent { deal_id: deal.id, new_status: DealStatus::Rejected, timestamp: event.timestamp, }); break; } } } }, // Handle other response types _ => {} } } } }
Voting Mechanics
Many Commander-specific cards feature voting mechanics (Council's Dilemma, Will of the Council). The voting system handles these cards' abilities:
#![allow(unused)] fn main() { /// Component for cards with voting mechanics #[derive(Component)] pub struct VotingMechanic { /// The type of voting mechanic pub voting_type: VotingType, /// The available options to vote for pub options: Vec<String>, /// How the results are applied pub resolution: VoteResolutionMethod, } /// Types of voting mechanics #[derive(Debug, Clone, PartialEq, Eq)] pub enum VotingType { /// Will of the Council - each player gets one vote WillOfCouncil, /// Council's Dilemma - each player votes for two different options CouncilsDilemma, /// Parley - a special voting variant Parley, /// Custom voting system Custom, } /// System for handling vote card resolution pub fn handle_voting_resolution( mut commands: Commands, mut vote_events: EventReader<VoteCompletionEvent>, voting_cards: Query<(Entity, &VotingMechanic)>, players: Query<Entity, With<Player>>, ) { for event in vote_events.read() { if let Ok((card_entity, voting_mechanic)) = voting_cards.get(event.source_card) { // Collect and tally votes let mut vote_counts: HashMap<String, u32> = HashMap::new(); for (player, vote) in &event.votes { *vote_counts.entry(vote.clone()).or_default() += 1; } // Apply effects based on voting results and resolution method match voting_mechanic.resolution { VoteResolutionMethod::MostVotes => { // Find option with most votes if let Some((winning_option, _)) = vote_counts .iter() .max_by_key(|(_, count)| *count) { // Apply effect for winning option apply_voting_effect( &mut commands, card_entity, winning_option, &event.votes ); } }, VoteResolutionMethod::AllVotes => { // Apply effect for each vote for (option, count) in vote_counts { // Apply effect scaled by vote count apply_voting_effect_scaled( &mut commands, card_entity, &option, count, &event.votes ); } }, // Handle other resolution methods _ => {} } } } } }
Example Voting Card Implementation
#![allow(unused)] fn main() { /// Implementation of Councils' Judgment pub fn create_councils_judgment() -> impl Bundle { ( CardName("Council's Judgment".to_string()), CardType::Sorcery, ManaCost::parse("{1}{W}{W}"), VotingMechanic { voting_type: VotingType::WillOfCouncil, options: vec!["Exile".to_string()], // Dynamic: each nonland permanent becomes an option resolution: VoteResolutionMethod::MostVotes, }, CouncilsJudgmentEffect, ) } #[derive(Component)] pub struct CouncilsJudgmentEffect; impl ResolveEffect for CouncilsJudgmentEffect { fn resolve(&self, world: &mut World, source: Entity, controller: Entity) { // Get all permanents as potential targets let targets: Vec<Entity> = get_valid_permanent_targets(world); // Start voting process world.send_event(InitiateVoteEvent { source: source, voting_type: VotingType::WillOfCouncil, options: targets.iter().map(|e| get_name_for_entity(world, *e)).collect(), initiator: controller, }); } } }
Threat Assessment
The threat assessment system helps players evaluate relative threats by:
- Displaying board state power metrics
- Tracking win proximity indicators
- Highlighting potential combo pieces
- Providing history of player actions and tendencies
#![allow(unused)] fn main() { /// Resource for tracking threat assessment #[derive(Resource)] pub struct ThreatAssessment { /// Calculated threat level for each player pub player_threats: HashMap<Entity, ThreatLevel>, /// Factors contributing to threat calculation pub threat_factors: HashMap<Entity, Vec<ThreatFactor>>, /// Historical threat trends pub threat_history: HashMap<Entity, VecDeque<HistoricalThreat>>, } /// System that updates threat assessment pub fn update_threat_assessment( mut threat_assessment: ResMut<ThreatAssessment>, players: Query<Entity, With<Player>>, life_totals: Query<&LifeTotal>, permanents: Query<(Entity, &Controller)>, commanders: Query<(Entity, &Commander, &Controller)>, graveyards: Query<(&Graveyard, &Owner)>, hands: Query<(&Hand, &Owner)>, ) { // Update threat metrics for each player for player in players.iter() { let mut threat_factors = Vec::new(); // Factor: Board presence let board_presence = permanents .iter() .filter(|(_, controller)| controller.0 == player) .count(); threat_factors.push(ThreatFactor { factor_type: ThreatFactorType::BoardPresence, value: board_presence as f32 * 0.5, }); // Factor: Commander damage potential if let Some((_, _, _)) = commanders .iter() .find(|(_, _, controller)| controller.0 == player) { // Calculate commander threat... threat_factors.push(ThreatFactor { factor_type: ThreatFactorType::CommanderPresence, value: 5.0, // Base threat for having commander }); } // Calculate other factors... // Update total threat let total_threat = threat_factors.iter().map(|f| f.value).sum(); threat_assessment.player_threats.insert(player, ThreatLevel(total_threat)); threat_assessment.threat_factors.insert(player, threat_factors); // Update history threat_assessment.threat_history .entry(player) .or_default() .push_back(HistoricalThreat { level: ThreatLevel(total_threat), turn: get_current_turn(), }); } } }
Goad Mechanics
Goad is a Commander-specific mechanic that forces creatures to attack:
#![allow(unused)] fn main() { /// Component for Goad effects #[derive(Component)] pub struct Goaded { /// The player who applied the goad effect pub goaded_by: Entity, /// When the goad effect expires pub expires_at: ExpiryTiming, } /// System that enforces Goad attack requirements pub fn enforce_goad_attack_requirement( goaded_creatures: Query<(Entity, &Goaded, &Controller)>, mut attack_requirement_events: EventWriter<AttackRequirementEvent>, turn_manager: Res<TurnManager>, ) { // Only check during active player's combat if turn_manager.current_phase != Phase::Combat(CombatStep::DeclareAttackers) { return; } let active_player = turn_manager.active_player; // Find creatures controlled by active player that are goaded for (entity, goaded, controller) in goaded_creatures.iter() { if controller.0 == active_player { // Creature must attack if able, and cannot attack player who goaded it attack_requirement_events.send(AttackRequirementEvent { creature: entity, must_attack: true, cannot_attack: vec![goaded.goaded_by], }); } } } }
Alliance Tracking
Alliances are temporary arrangements between players:
#![allow(unused)] fn main() { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Alliance { /// Players in the alliance pub members: Vec<Entity>, /// Strength/type of alliance pub strength: AllianceStrength, /// Purpose of the alliance pub purpose: String, /// When the alliance was formed pub formed_at: f64, /// When the alliance expires (if temporary) pub expires_at: Option<f64>, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum AllianceStrength { /// Weak alliance of convenience Weak, /// Standard alliance Moderate, /// Strong alliance with significant shared interests Strong, } /// System for tracking and updating alliances pub fn update_alliances( mut politics_components: Query<&mut PoliticsComponent>, time: Res<Time>, mut alliance_events: EventReader<AllianceEvent>, ) { let current_time = time.elapsed_seconds_f64(); // Process alliance events for event in alliance_events.read() { match event.event_type { AllianceEventType::Form => { // Create new alliance let alliance = Alliance { members: event.members.clone(), strength: event.strength.clone(), purpose: event.purpose.clone(), formed_at: current_time, expires_at: event.expiration, }; // Update politics components for all members for member in &event.members { if let Ok(mut politics) = politics_components.get_mut(*member) { for other_member in &event.members { if *other_member != *member { politics.alliances.insert(*other_member, alliance.strength.clone()); } } } } }, AllianceEventType::Break => { // Handle alliance breaking for member in &event.members { if let Ok(mut politics) = politics_components.get_mut(*member) { for other_member in &event.members { if *other_member != *member { politics.alliances.remove(other_member); // Record broken alliance in history politics.past_alliances.push(PastAlliance { with: *other_member, strength: event.strength.clone(), purpose: event.purpose.clone(), formed_at: event.formed_at.unwrap_or(0.0), broken_at: current_time, broken_reason: event.reason.clone(), }); } } } } }, } } // Check for expired alliances for mut politics in politics_components.iter_mut() { // Implementation for alliance expiration... } } }
AI Integration
For games with AI opponents, the politics system:
- Models AI political decision making based on configured personalities
- Evaluates deal proposals based on game state and risk assessment
- Tracks human player tendencies for future political decisions
- Simulates realistic political behavior for different AI difficulty levels
#![allow(unused)] fn main() { #[derive(Resource)] pub struct AIPoliticsConfig { /// AI personality profiles pub personalities: HashMap<Entity, AIPoliticalPersonality>, /// AI decision making parameters pub decision_weights: AIPoliticsWeights, /// Historic interaction with players pub player_interaction_history: HashMap<Entity, PlayerInteractionHistory>, } #[derive(Clone, Debug)] pub struct AIPoliticalPersonality { /// How aggressive the AI is pub aggression: f32, /// How trustworthy the AI is pub trustworthiness: f32, /// How risk-averse the AI is pub risk_aversion: f32, /// How vengeful the AI is pub vengefulness: f32, /// How the AI evaluates deals pub deal_evaluation_strategy: DealEvaluationStrategy, } /// System for AI deal evaluation pub fn ai_evaluate_deal( ai_politics_config: Res<AIPoliticsConfig>, threat_assessment: Res<ThreatAssessment>, board_state: Res<BoardState>, deal: &Deal, ai_player: Entity, ) -> DealEvaluationResult { if let Some(personality) = ai_politics_config.personalities.get(&ai_player) { // Get AI personality let trust_factor = personality.trustworthiness; let risk_factor = personality.risk_aversion; // Calculate deal value let mut deal_value = 0.0; for term in &deal.terms { match term { DealTerm::NonAggression { target, duration } => { // Value based on target threat and duration let target_threat = threat_assessment.player_threats .get(target) .map(|t| t.0) .unwrap_or(0.0); deal_value += target_threat * (*duration as f32) * 0.5; }, // Evaluate other term types... _ => {}, } } // Adjust for proposer's trustworthiness let proposer_trust = ai_politics_config.player_interaction_history .get(&ai_player) .and_then(|h| h.player_trust.get(&deal.proposer)) .copied() .unwrap_or(0.5); deal_value *= proposer_trust; // Make decision based on value if deal_value > personality.deal_threshold { DealEvaluationResult::Accept } else { DealEvaluationResult::Reject { reason: "Not valuable enough".to_string() } } } else { // Default rejection if no personality DealEvaluationResult::Reject { reason: "No AI personality configured".to_string() } } } }
UI Components
The multiplayer politics UI provides:
- Deal proposal interface with customizable terms
- Alliance status indicators
- Threat assessment visualization
- Communication tools with appropriate filters
- Deal history and player reputation tracking
#![allow(unused)] fn main() { #[derive(Component)] pub struct PoliticsUIState { /// Currently selected player for political actions pub selected_player: Option<Entity>, /// Deal being constructed pub draft_deal: Option<DraftDeal>, /// UI mode pub ui_mode: PoliticsUIMode, } /// System for rendering politics UI pub fn render_politics_ui( mut commands: Commands, mut egui_context: ResMut<EguiContext>, politics_ui_state: Res<PoliticsUIState>, politics_components: Query<&PoliticsComponent>, players: Query<(Entity, &PlayerName)>, threat_assessment: Res<ThreatAssessment>, ) { // Implementation of politics UI rendering... } }
Integration with Other Systems
The politics system integrates with several other game systems:
Combat System Integration
#![allow(unused)] fn main() { /// System for applying political factors to combat pub fn apply_politics_to_combat( politics_components: Query<&PoliticsComponent>, mut attack_events: EventReader<DeclareAttackerEvent>, mut attack_modifiers: EventWriter<AttackModifierEvent>, alliances: Query<&Alliance>, ) { for event in attack_events.read() { if let Ok(attacker_politics) = politics_components.get(event.controller) { // Check for deals preventing attacks for deal in &attacker_politics.active_deals { for term in &deal.terms { if let DealTerm::NonAggression { target, duration } = term { if *target == event.defender && deal.status == DealStatus::Active { // This attack would violate a non-aggression deal attack_modifiers.send(AttackModifierEvent { attacker: event.attacker, defender: event.defender, modification: AttackModification::PreventAttack { reason: "Non-aggression deal in effect".to_string(), }, }); } } } } // Check for alliances if let Some(alliance_strength) = attacker_politics.alliances.get(&event.defender) { // Alliance strength affects whether attack is allowed if *alliance_strength == AllianceStrength::Strong { attack_modifiers.send(AttackModifierEvent { attacker: event.attacker, defender: event.defender, modification: AttackModification::DissuadeAttack { reason: "Strong alliance in effect".to_string(), penalty: 2.0, }, }); } } } } } }
Card Effect Integration
#![allow(unused)] fn main() { /// System for applying political factors to targeted effects pub fn apply_politics_to_targeting( politics_components: Query<&PoliticsComponent>, mut targeting_events: EventReader<TargetSelectionEvent>, mut targeting_modifiers: EventWriter<TargetingModifierEvent>, ) { for event in targeting_events.read() { if let Ok(caster_politics) = politics_components.get(event.controller) { // Check for deals affecting targeting for deal in &caster_politics.active_deals { for term in &deal.terms { // Handle various deal terms affecting targeting match term { DealTerm::NoCounterspell { target, .. } => { if *target == event.target_controller && is_counterspell(event.source) { // This targeting would violate a no-counterspell deal targeting_modifiers.send(TargetingModifierEvent { source: event.source, target: event.target, modification: TargetingModification::PreventTargeting { reason: "No-counterspell deal in effect".to_string(), }, }); } }, // Handle other deal terms... _ => {}, } } } // Check for alliances affecting targeting if let Some(alliance_strength) = caster_politics.alliances.get(&event.target_controller) { if is_negative_effect(event.source, event.target) { match alliance_strength { AllianceStrength::Strong => { targeting_modifiers.send(TargetingModifierEvent { source: event.source, target: event.target, modification: TargetingModification::DissuadeTargeting { reason: "Strong alliance in effect".to_string(), penalty: 3.0, }, }); }, // Handle other alliance levels... _ => {}, } } } } } } }
Constraints and Limitations
The politics system operates within these constraints:
- No rules enforcement of political agreements (maintaining game integrity)
- Appropriate limits on information sharing for hidden information
- Configurable table talk policies to match playgroup preferences
- Balance between automation and player agency in political decisions
Testing Politics Features
#![allow(unused)] fn main() { #[test] fn test_deal_creation_and_acceptance() { let mut app = App::new(); setup_test_game(&mut app); // Create players let player1 = app.world.spawn(( Player, PlayerName("Player 1".to_string()), PoliticsComponent::default(), )).id(); let player2 = app.world.spawn(( Player, PlayerName("Player 2".to_string()), PoliticsComponent::default(), )).id(); // Create a deal proposal let deal_terms = vec![ DealTerm::NonAggression { target: player2, duration: 2, }, ]; app.world.send_event(DealProposalEvent { proposer: player1, targets: vec![player2], terms: deal_terms, expiration: Some(DealExpiration::Turns(2)), timestamp: 0.0, }); app.update(); // Check that deal was created let politics1 = app.world.get::<PoliticsComponent>(player1).unwrap(); assert_eq!(politics1.active_deals.len(), 1); assert_eq!(politics1.active_deals[0].status, DealStatus::Proposed); // Accept the deal let deal_id = politics1.active_deals[0].id; app.world.send_event(DealResponseEvent { deal_id, response_type: DealResponseType::Accept, player: player2, timestamp: 1.0, }); app.update(); // Verify deal is now active let politics1_updated = app.world.get::<PoliticsComponent>(player1).unwrap(); assert_eq!(politics1_updated.active_deals[0].status, DealStatus::Active); assert_eq!(politics1_updated.active_deals[0].acceptors, vec![player2]); } #[test] fn test_goad_mechanics() { let mut app = App::new(); setup_test_game(&mut app); // Create players let player1 = app.world.spawn(Player).id(); let player2 = app.world.spawn(Player).id(); // Create a creature let creature = app.world.spawn(( CardName("Test Creature".to_string()), CardType::Creature, Power(3), Toughness(3), Controller(player1), OnBattlefield, )).id(); // Goad the creature app.world.entity_mut(creature).insert(Goaded { goaded_by: player2, expires_at: ExpiryTiming::EndOfTurn, }); // Set up combat phase app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::DeclareAttackers); app.world.resource_mut::<TurnManager>().active_player = player1; app.update(); // Get attack requirements let attack_requirements = app.world.resource::<Events<AttackRequirementEvent>>() .get_reader() .read(&app.world.resource::<Events<AttackRequirementEvent>>()) .collect::<Vec<_>>(); // Verify goad requirements are enforced assert!(!attack_requirements.is_empty()); assert_eq!(attack_requirements[0].creature, creature); assert!(attack_requirements[0].must_attack); assert_eq!(attack_requirements[0].cannot_attack, vec![player2]); } }
Related Resources
- Politics Testing: Details on testing political mechanics
- Commander-Specific Cards: Cards with political mechanics
- Multiplayer Combat: How combat works in multiplayer
- Goad Implementation: More details on goad effects
Politics System Testing
Overview
This document outlines the testing approach for the Multiplayer Politics system within the Commander game engine. Due to the complex nature of political interactions and their impact on game state, thorough testing is essential to ensure correct behavior and integration with other components.
Test Categories
The Politics system testing is organized into several key categories:
1. Unit Tests
Unit tests focus on isolated functionality of the Politics system components:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn test_monarch_transitions() { // Test monarch assignment and transitions } #[test] fn test_vote_tallying() { // Test vote counting and resolution } #[test] fn test_deal_validation() { // Test deal validation logic } #[test] fn test_alliance_formation() { // Test alliance creation and tracking } #[test] fn test_goad_effects() { // Test goad mechanics and expiration } } }
2. Integration Tests
Integration tests verify Politics system interactions with other game systems:
- Combat integration (goad effects, alliance-based combat restrictions)
- Turn structure integration (monarch draw effects, deal expiration)
- Card effects that interact with political mechanics
- UI feedback for political events
- Threat assessment impact on AI decision making
3. End-to-End Tests
End-to-end tests simulate full game scenarios involving political elements:
- Four-player game with complex political interactions
- AI decision-making in political contexts
- Network synchronization of political state
- Full game simulations with political mechanics enabled
Testing The Monarch Mechanic
The Monarch mechanic requires specific test cases:
-
Monarch Assignment
- Test initial monarch assignment
- Test monarch transfer through card effects
- Test monarch transfer through combat damage
-
Monarch Effects
- Verify correct card draw during end step
- Test interaction with replacement effects
- Validate monarch status persistence across turns
-
Edge Cases
- Test monarch behavior when player loses/leaves game
- Test simultaneous monarch-granting effects
- Test prevention of monarch transfer
#![allow(unused)] fn main() { #[test] fn test_monarch_combat_transfer() { let mut app = App::new(); setup_test_game(&mut app); // Create players let player1 = app.world.spawn(Player).id(); let player2 = app.world.spawn(Player).id(); // Set initial monarch let mut monarch_state = MonarchState { current_monarch: Some(player1), last_changed: Some(0.0), }; app.insert_resource(monarch_state); // Player entity should have Monarch component app.world.entity_mut(player1).insert(Monarch); // Create attacking creature let creature = app.world.spawn(( CardName("Test Creature".to_string()), CardType::Creature, Power(3), Toughness(3), Controller(player2), OnBattlefield, )).id(); // Send combat damage event app.world.send_event(CombatDamageEvent { source: creature, target: player1, amount: 3, is_commander_damage: false, attacking_player_controller: player2, defending_player: player1, }); app.update(); // Verify monarch transferred let updated_monarch = app.world.resource::<MonarchState>(); assert_eq!(updated_monarch.current_monarch, Some(player2)); assert!(app.world.entity(player2).contains::<Monarch>()); assert!(!app.world.entity(player1).contains::<Monarch>()); } }
Testing The Voting System
The voting system tests include:
-
Vote Initialization
- Test vote creation and option definition
- Validate vote accessibility to all players
- Test timing restrictions on votes
-
Vote Casting
- Test basic vote casting mechanics
- Test vote weight modifiers (e.g., extra votes from effects)
- Validate vote time limits
- Test voting for multiple options (Council's Dilemma)
-
Vote Resolution
- Test tie-breaking logic
- Verify correct application of voting results
- Test effects that modify vote outcomes
- Test scaling effects based on vote counts
#![allow(unused)] fn main() { #[test] fn test_voting_resolution() { let mut app = App::new(); setup_test_game(&mut app); // Create players let player1 = app.world.spawn(Player).id(); let player2 = app.world.spawn(Player).id(); let player3 = app.world.spawn(Player).id(); // Create voting card let voting_card = app.world.spawn(( CardName("Council's Judgment".to_string()), VotingMechanic { voting_type: VotingType::WillOfCouncil, options: vec!["Option A".to_string(), "Option B".to_string()], resolution: VoteResolutionMethod::MostVotes, }, Controller(player1), )).id(); // Cast votes let votes = HashMap::from([ (player1, "Option A".to_string()), (player2, "Option B".to_string()), (player3, "Option A".to_string()), ]); // Send vote completion event app.world.send_event(VoteCompletionEvent { source_card: voting_card, votes, timestamp: 1.0, }); app.update(); // Verify correct effect was applied for winning option "Option A" // Further assertions based on the expected outcome } }
Testing The Deal System
The deal system requires comprehensive testing:
-
Deal Creation
- Test deal proposal structure
- Validate term specification
- Test duration settings
- Test deal limits per player
-
Deal Negotiation
- Test acceptance/rejection mechanics
- Test counter-proposal handling
- Validate notification system
- Test multi-player deals
-
Deal Enforcement
- Test automatic deal monitoring
- Validate deal violation detection
- Test consequences application
- Test enforcement of complex terms
-
Deal History
- Test deal history tracking
- Validate reputation system updates
- Test history influence on AI decisions
- Test broken deal statistics
#![allow(unused)] fn main() { #[test] fn test_deal_lifecycle() { let mut app = App::new(); setup_test_game(&mut app); // Create players with politics components let player1 = app.world.spawn(( Player, PlayerName("Player 1".to_string()), PoliticsComponent::default(), )).id(); let player2 = app.world.spawn(( Player, PlayerName("Player 2".to_string()), PoliticsComponent::default(), )).id(); // Create a deal proposal let deal_terms = vec![ DealTerm::NonAggression { target: player2, duration: 2, }, ]; app.world.send_event(DealProposalEvent { proposer: player1, targets: vec![player2], terms: deal_terms, expiration: Some(DealExpiration::Turns(2)), timestamp: 0.0, }); app.update(); // Verify deal proposal created let politics1 = app.world.get::<PoliticsComponent>(player1).unwrap(); assert_eq!(politics1.active_deals.len(), 1); assert_eq!(politics1.active_deals[0].status, DealStatus::Proposed); // Accept the deal let deal_id = politics1.active_deals[0].id; app.world.send_event(DealResponseEvent { deal_id, response_type: DealResponseType::Accept, player: player2, timestamp: 1.0, }); app.update(); // Verify deal is now active let politics1_updated = app.world.get::<PoliticsComponent>(player1).unwrap(); assert_eq!(politics1_updated.active_deals[0].status, DealStatus::Active); // Test deal expiration advance_game_turns(&mut app, 2); app.update(); // Verify deal has expired let politics1_final = app.world.get::<PoliticsComponent>(player1).unwrap(); assert_eq!(politics1_final.active_deals[0].status, DealStatus::Expired); } }
Testing Alliance Mechanics
The alliance system requires specific test cases:
-
Alliance Formation
- Test alliance creation
- Test alliance strength levels
- Validate multi-player alliance formation
-
Alliance Effects
- Test combat restrictions based on alliances
- Test targeting restrictions for allied players
- Validate benefits between allied players
-
Alliance Dissolution
- Test voluntary alliance breaking
- Test automatic alliance expiration
- Validate alliance history tracking
#![allow(unused)] fn main() { #[test] fn test_alliance_combat_restrictions() { let mut app = App::new(); setup_test_game(&mut app); // Create players let player1 = app.world.spawn(( Player, PoliticsComponent::default(), )).id(); let player2 = app.world.spawn(( Player, PoliticsComponent::default(), )).id(); // Create strong alliance let mut politics1 = app.world.get_mut::<PoliticsComponent>(player1).unwrap(); politics1.alliances.insert(player2, AllianceStrength::Strong); // Create creature let creature = app.world.spawn(( CardType::Creature, Controller(player1), OnBattlefield, )).id(); // Attempt to attack allied player app.world.send_event(DeclareAttackerEvent { attacker: creature, defender: player2, controller: player1, }); app.update(); // Check for attack modification event let attack_mods = app.world.resource::<Events<AttackModifierEvent>>() .get_reader() .read(&app.world.resource::<Events<AttackModifierEvent>>()) .collect::<Vec<_>>(); assert!(!attack_mods.is_empty()); assert_eq!(attack_mods[0].attacker, creature); assert_eq!(attack_mods[0].defender, player2); // Verify correct modification type (should be DissuadeAttack for strong alliance) match &attack_mods[0].modification { AttackModification::DissuadeAttack { reason, penalty } => { assert!(reason.contains("alliance")); assert!(*penalty > 0.0); }, _ => panic!("Expected DissuadeAttack modification"), } } }
Testing Goad Mechanics
Goad mechanics require specific testing:
-
Goad Application
- Test applying goad to creatures
- Test goad duration tracking
- Test multiple simultaneous goad effects
-
Goad Attack Requirements
- Test forced attack requirement
- Test restriction on attacking goader
- Validate legal target determination
-
Goad Edge Cases
- Test goad with no legal attack targets
- Test goad when creature cannot attack
- Test interaction with other attack restrictions
#![allow(unused)] fn main() { #[test] fn test_goad_mechanics() { let mut app = App::new(); setup_test_game(&mut app); // Create 3 players let player1 = app.world.spawn(Player).id(); let player2 = app.world.spawn(Player).id(); let player3 = app.world.spawn(Player).id(); // Create a creature let creature = app.world.spawn(( CardName("Test Creature".to_string()), CardType::Creature, Power(3), Toughness(3), Controller(player1), OnBattlefield, CanAttack(true), )).id(); // Goad the creature app.world.entity_mut(creature).insert(Goaded { goaded_by: player2, expires_at: ExpiryTiming::EndOfTurn, }); // Set up combat phase app.world.resource_mut::<TurnManager>().current_phase = Phase::Combat(CombatStep::DeclareAttackers); app.world.resource_mut::<TurnManager>().active_player = player1; app.update(); // Get attack requirements let attack_requirements = app.world.resource::<Events<AttackRequirementEvent>>() .get_reader() .read(&app.world.resource::<Events<AttackRequirementEvent>>()) .collect::<Vec<_>>(); // Verify goad requirements are enforced assert!(!attack_requirements.is_empty()); assert_eq!(attack_requirements[0].creature, creature); assert!(attack_requirements[0].must_attack); assert_eq!(attack_requirements[0].cannot_attack, vec![player2]); // Advance turn to test goad expiration advance_turn(&mut app); // Verify goad has expired assert!(!app.world.entity(creature).contains::<Goaded>()); } }
Testing Threat Assessment
The threat assessment system requires verification:
-
Threat Calculation
- Test basic threat score calculation
- Test contribution of different threat factors
- Validate dynamic threat updates
-
Threat Visualization
- Test threat UI display
- Test threat history tracking
- Validate threat factor breakdown
-
AI Integration
- Test AI use of threat assessment
- Validate threat-based targeting
- Test strategic adjustments based on threat
#![allow(unused)] fn main() { #[test] fn test_threat_assessment() { let mut app = App::new(); setup_test_game(&mut app); // Create players let player1 = app.world.spawn(Player).id(); let player2 = app.world.spawn(Player).id(); // Initialize threat assessment app.insert_resource(ThreatAssessment { player_threats: HashMap::new(), threat_factors: HashMap::new(), threat_history: HashMap::new(), }); // Create board state for player1 for _ in 0..5 { app.world.spawn(( CardType::Creature, Controller(player1), OnBattlefield, )); } // Create commander for player1 app.world.spawn(( CardType::Creature, Commander, Controller(player1), OnBattlefield, )); // Create smaller board for player2 for _ in 0..2 { app.world.spawn(( CardType::Creature, Controller(player2), OnBattlefield, )); } // Run threat assessment system app.add_systems(Update, update_threat_assessment); app.update(); // Check threat levels let threat = app.world.resource::<ThreatAssessment>(); // Player1 should have higher threat than player2 let player1_threat = threat.player_threats.get(&player1).unwrap(); let player2_threat = threat.player_threats.get(&player2).unwrap(); assert!(player1_threat.0 > player2_threat.0); // Verify threat factors were recorded assert!(!threat.threat_factors.get(&player1).unwrap().is_empty()); // Check commander presence factor let commander_factor = threat.threat_factors.get(&player1).unwrap() .iter() .find(|f| matches!(f.factor_type, ThreatFactorType::CommanderPresence)); assert!(commander_factor.is_some()); } }
Mock Testing Approaches
For complex political scenarios, mock testing is employed:
#![allow(unused)] fn main() { #[test] fn test_complex_political_scenario() { let mut app = App::new(); // Setup test environment app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin) .add_systems(Startup, setup_test_scenario); // Mock player actions let players = setup_four_player_game(&mut app); // Simulate complex political scenario simulate_monarchy_contest(&mut app, &players); simulate_deal_making(&mut app, &players); simulate_voting_session(&mut app, &players); simulate_alliance_formation(&mut app, &players); simulate_threat_based_targeting(&mut app, &players); // Verify expected outcomes verify_political_state(&app, &expected_state); } }
Property-Based Testing
For robust validation of political mechanics, property-based testing is used:
#![allow(unused)] fn main() { #[test] fn test_voting_properties() { // Property: All votes must be counted proptest!(|(votes: Vec<(Entity, VoteChoice)>)| { let result = tally_votes(&votes); assert_eq!(result.total_votes, votes.len()); }); // Property: Winning option must have most votes proptest!(|(votes: Vec<(Entity, VoteChoice)>)| { let result = tally_votes(&votes); // Verify winning option has highest count }); // Property: Goad must always allow at least one legal attack target proptest!(|(players: Vec<Entity>, goaded_by: Entity)| { let legal_targets = determine_legal_attack_targets(players.clone(), goaded_by); // Verify at least one legal target if there are enough players if players.len() > 2 { assert!(!legal_targets.is_empty()); } }); } }
Network Testing
Political systems must be tested for correct network behavior:
-
State Synchronization
- Test monarch status synchronization
- Validate vote transmission and collection
- Test deal state synchronization
- Test alliance state replication
-
Latency Handling
- Test delayed political decisions
- Validate timeout handling for votes/deals
- Test recovery from connection issues
-
Conflict Resolution
- Test resolution of conflicting political actions
- Validate deterministic outcomes across peers
Regression Test Suite
The Politics system maintains a regression test suite covering:
- Known past issues and their fixes
- Edge cases discovered during development
- Community-reported political interaction bugs
Performance Testing
Political mechanics are tested for performance impacts:
-
Scaling Tests
- Performance with many simultaneous deals
- Vote tallying with large player counts
- Political state update propagation
- Threat assessment with complex board states
-
Memory Usage
- Test memory footprint of complex political states
- Validate cleanup of expired political objects
Test Fixtures
Reusable test fixtures include:
- Standard 4-player table setup
- Pre-defined political scenarios
- Mock AI political personalities
- Reference deal/vote templates
- Standard threat assessment profiles
Continuous Integration
Politics tests are integrated into CI with:
- Automated test runs on PR submission
- Coverage reports for political mechanics
- Performance comparison against baseline
- Parallel testing of different political scenarios
Related Resources
- Multiplayer Politics - Core politics system documentation
- Politics Tests - Specific test case implementations
- Commander-Specific Cards - Cards with political mechanics
Partner Commanders
This document details the implementation of partner commanders and related mechanics in the Commander format.
Overview
Partner is a mechanic introduced in Commander 2016 that allows a deck to have two commanders instead of one. There are several variations of the partner mechanic:
- Traditional Partners: Cards with the text "Partner" can pair with any other card that has Partner
- Partner With: Cards with "Partner with [specific card]" can only pair with that specific card
- Background: A commander that can have a Background enchantment as a second commander
- Friends Forever: Cards with "Friends Forever" can pair with any other card that has Friends Forever
Rules Implementation
Core Partner Rules
The partner mechanic modifies several fundamental Commander rules:
- Two commanders instead of one
- Color identity is the combined colors of both commanders
- Starting life total and commander damage tracking apply to each commander separately
- Both commanders start in the command zone
- Commander tax applies to each commander separately
Implementation Details
#![allow(unused)] fn main() { /// Component marking an entity as a partner commander #[derive(Component, Clone, Debug)] pub enum PartnerType { /// Can partner with any other commander with Partner Universal, /// Can only partner with a specific commander Specific(Entity), /// Can have a Background as a partner CanHaveBackground, /// Is a Background enchantment IsBackground, /// Can partner with any Friends Forever commander FriendsForever, } /// Resource tracking partner commanders #[derive(Resource)] pub struct PartnerSystem { /// Maps players to their partner commanders pub player_partners: HashMap<Entity, Vec<Entity>>, /// Tracks which commanders are partnered together pub partnered_with: HashMap<Entity, Entity>, } /// System to validate partner legality during deck construction pub fn validate_partner_legality( player: Entity, deck: &Deck, partners: &Query<(Entity, &CardName, &PartnerType)>, ) -> Result<(), DeckValidationError> { // Get commanders marked as partners let commander_entities = deck.get_commanders(); // Filter to keep only entities with Partner component let partner_entities: Vec<Entity> = partners .iter_many(commander_entities) .map(|(entity, _, _)| entity) .collect(); // Validate partner relationships match partner_entities.len() { 0 => Ok(()), // No partners, standard single commander 1 => Err(DeckValidationError::SinglePartnerNotAllowed), // Single partner needs a pair 2 => validate_two_partners(&partner_entities, partners), // Check if these two can partner _ => Err(DeckValidationError::TooManyPartners), // More than 2 partners not allowed } } /// Validates if two commanders can be partners fn validate_two_partners( entities: &[Entity], partners: &Query<(Entity, &CardName, &PartnerType)>, ) -> Result<(), DeckValidationError> { let (entity_a, name_a, type_a) = partners.get(entities[0]).unwrap(); let (entity_b, name_b, type_b) = partners.get(entities[1]).unwrap(); match (type_a, type_b) { // Universal partners can pair with any other universal partner (PartnerType::Universal, PartnerType::Universal) => Ok(()), // Specific partners can only pair with their named partner (PartnerType::Specific(target), _) if *target == entity_b => Ok(()), (_, PartnerType::Specific(target)) if *target == entity_a => Ok(()), // Background pairings (PartnerType::CanHaveBackground, PartnerType::IsBackground) => Ok(()), (PartnerType::IsBackground, PartnerType::CanHaveBackground) => Ok(()), // Friends Forever pairings (PartnerType::FriendsForever, PartnerType::FriendsForever) => Ok(()), // All other combinations are invalid _ => Err(DeckValidationError::InvalidPartnerCombination { commander_a: name_a.0.clone(), commander_b: name_b.0.clone(), }), } } }
Color Identity with Partners
A deck with partner commanders uses the combined color identity of both commanders:
#![allow(unused)] fn main() { /// Calculate color identity for a deck with partners pub fn calculate_partner_color_identity( commanders: &[Entity], identity_query: &Query<&ColorIdentity>, ) -> ColorIdentity { let mut combined_identity = ColorIdentity::default(); for commander in commanders { if let Ok(identity) = identity_query.get(*commander) { combined_identity = combined_identity.union(identity); } } combined_identity } }
Partner Variations
Partner With
The "Partner with" mechanic has additional functionality beyond allowing two commanders:
- When either commander enters the battlefield, its controller may search their library for the other
- This tutor effect requires special implementation
#![allow(unused)] fn main() { /// Component for the "Partner with" tutoring ability #[derive(Component)] pub struct PartnerWithTutorAbility { pub partner_name: String, } /// System that handles the "Partner with" tutoring ability pub fn handle_partner_with_tutor( mut commands: Commands, mut entered_battlefield: EventReader<EnteredBattlefieldEvent>, tutor_abilities: Query<(&PartnerWithTutorAbility, &Owner)>, mut tutor_events: EventWriter<TutorEvent>, ) { for event in entered_battlefield.read() { if let Ok((ability, owner)) in tutor_abilities.get(event.entity) { // Create a tutor effect allowing player to search for the partner tutor_events.send(TutorEvent { player: owner.0, card_name: ability.partner_name.clone(), origin: event.entity, destination: Zone::Hand, optional: true, }); } } } }
Background
The Background mechanic, introduced in Commander Legends: Battle for Baldur's Gate, allows certain commanders to have a Background enchantment as their second commander:
#![allow(unused)] fn main() { /// Component marking a card as a Background #[derive(Component)] pub struct Background; /// Component for commanders that can have a Background #[derive(Component)] pub struct CanHaveBackground; /// System to validate Background legality pub fn validate_background_legality( commanders: &[Entity], can_have_query: &Query<Entity, With<CanHaveBackground>>, background_query: &Query<Entity, With<Background>>, ) -> Result<(), DeckValidationError> { if commanders.len() != 2 { return Ok(()); } let has_commander_with_background = can_have_query .iter_many(commanders) .count() == 1; let has_background = background_query .iter_many(commanders) .count() == 1; if has_commander_with_background && has_background { Ok(()) } else if has_commander_with_background || has_background { Err(DeckValidationError::IncompleteBackgroundPairing) } else { Ok(()) // Not using Background mechanic } } }
User Interface Considerations
The UI needs special handling for partner commanders:
- Both commanders need to be displayed in the command zone
- Players need a way to choose which commander to cast
- Commander tax display needs to track each commander separately
Testing Partner Mechanics
#![allow(unused)] fn main() { #[test] fn test_universal_partners() { let mut app = App::new(); app.add_systems(Startup, setup_test_partners); app.add_systems(Update, validate_partner_legality); // Test universal partners (e.g., Thrasios and Tymna) let thrasios = app.world.spawn(( CardName("Thrasios, Triton Hero".to_string()), PartnerType::Universal, )).id(); let tymna = app.world.spawn(( CardName("Tymna the Weaver".to_string()), PartnerType::Universal, )).id(); let deck = create_test_deck(vec![thrasios, tymna]); let result = validate_deck(&app.world, deck); assert!(result.is_ok()); } #[test] fn test_partners_with() { // Test for "Partner with" mechanic (e.g., Brallin and Shabraz) // Implementation details... } #[test] fn test_background() { // Test for Background mechanic // Implementation details... } }
Related Documentation
- Commander Damage: How commander damage is tracked with partner commanders
- Command Zone: How partners behave in the command zone
- Commander Tax: How tax is applied to each partner
- Color Identity: How color identity is calculated with partners
Commander Ninjutsu
This document details the implementation of the Commander Ninjutsu mechanic in the Commander format.
Overview
Commander Ninjutsu is a Commander-specific variant of the Ninjutsu mechanic, introduced on the card "Yuriko, the Tiger's Shadow" from Commander 2018. It allows a commander with this ability to be put onto the battlefield from the command zone, bypassing commander tax.
Mechanic Definition
Commander Ninjutsu {Cost} — {Cost}, Return an unblocked attacker you control to hand: Put this card from the command zone onto the battlefield tapped and attacking.
Key differences from regular Ninjutsu:
- It can be activated from the command zone (instead of only from hand)
- It bypasses the need to cast the commander, avoiding commander tax
- It allows the commander to enter the battlefield directly attacking
Rules Implementation
Core Commander Ninjutsu Rules
#![allow(unused)] fn main() { /// Component for cards with Commander Ninjutsu #[derive(Component, Clone, Debug)] pub struct CommanderNinjutsu { /// Mana cost to activate the ability pub cost: ManaCost, } /// Event triggered when a Commander Ninjutsu ability is activated #[derive(Event)] pub struct CommanderNinjutsuEvent { /// The commander with ninjutsu being put onto the battlefield pub commander: Entity, /// The creature being returned to hand pub returned_creature: Entity, /// The player being attacked pub defending_player: Entity, /// The player activating the ability pub controller: Entity, } /// System that handles Commander Ninjutsu activation pub fn handle_commander_ninjutsu( mut commands: Commands, mut ninjutsu_events: EventReader<CommanderNinjutsuEvent>, mut zone_transitions: EventWriter<ZoneTransitionEvent>, mut attacking_status: EventWriter<SetAttackingEvent>, command_zone: Query<&CommandZone>, ) { for event in ninjutsu_events.read() { // Verify commander is in command zone if !is_in_command_zone(event.commander, &command_zone) { continue; } // 1. Return the unblocked attacker to hand zone_transitions.send(ZoneTransitionEvent { entity: event.returned_creature, from: Zone::Battlefield, to: Zone::Hand, cause: TransitionCause::Ability { source: event.commander, ability_name: "Commander Ninjutsu".to_string(), }, }); // 2. Put commander onto battlefield zone_transitions.send(ZoneTransitionEvent { entity: event.commander, from: Zone::Command, to: Zone::Battlefield, cause: TransitionCause::Ability { source: event.commander, ability_name: "Commander Ninjutsu".to_string(), }, }); // 3. Set commander as tapped and attacking commands.entity(event.commander).insert(Tapped); attacking_status.send(SetAttackingEvent { attacker: event.commander, defending_player: event.defending_player, }); } } }
Activation Requirements
Commander Ninjutsu can only be activated during specific game states:
#![allow(unused)] fn main() { /// System to determine when Commander Ninjutsu can be activated pub fn can_activate_commander_ninjutsu( commander: Entity, controller: Entity, game_state: &GameState, command_zone: &Query<&CommandZone>, ninjutsu_abilities: &Query<&CommanderNinjutsu>, attacking_creatures: &Query<(Entity, &AttackingStatus, &Controller)>, ) -> bool { // 1. Must be in the combat phase, after blockers are declared if !matches!(game_state.current_phase, Phase::Combat(CombatStep::DeclareBlockers | CombatStep::CombatDamage)) { return false; } // 2. Commander must be in command zone if !is_in_command_zone(commander, command_zone) { return false; } // 3. Must have Commander Ninjutsu ability if !ninjutsu_abilities.contains(commander) { return false; } // 4. Must have an unblocked attacker to return to hand let has_unblocked_attacker = attacking_creatures .iter() .any(|(entity, status, creature_controller)| { creature_controller.0 == controller && status.is_unblocked() }); has_unblocked_attacker } }
Mana Cost and Commander Tax
The Commander Ninjutsu ability itself has a fixed cost that does not increase when the commander is put into the command zone:
#![allow(unused)] fn main() { /// Function to get the mana cost for activating Commander Ninjutsu pub fn get_commander_ninjutsu_cost( commander: Entity, ninjutsu_abilities: &Query<&CommanderNinjutsu>, ) -> ManaCost { // Commander Ninjutsu cost is fixed and doesn't include commander tax if let Ok(ninjutsu) = ninjutsu_abilities.get(commander) { ninjutsu.cost.clone() } else { ManaCost::default() // Fallback, shouldn't happen } } }
However, the normal casting cost of the commander still increases each time the commander is put into the command zone from anywhere:
#![allow(unused)] fn main() { /// System that tracks commander movement to command zone to apply tax pub fn track_commander_tax( mut tax_events: EventWriter<IncrementCommanderTaxEvent>, mut zone_transition_events: EventReader<ZoneTransitionEvent>, commander_query: Query<&Commander>, ) { for event in zone_transition_events.read() { if event.to == Zone::Command && commander_query.contains(event.entity) { // Increment commander tax, even for commanders with Ninjutsu tax_events.send(IncrementCommanderTaxEvent { commander: event.entity, }); } } } }
User Interface Considerations
The UI needs special handling for Commander Ninjutsu:
- Ability must be presented as an option during combat after blockers are declared
- UI must show which unblocked attackers can be returned to hand
- Cost display should show the fixed Ninjutsu cost (not affected by commander tax)
#![allow(unused)] fn main() { /// Function to get available Commander Ninjutsu actions pub fn get_commander_ninjutsu_actions( player: Entity, game_state: &GameState, commanders: &Query<(Entity, &Commander, &CommanderNinjutsu, &Owner)>, unblocked_attackers: &Query<(Entity, &AttackingStatus, &Controller)>, ) -> Vec<CommanderNinjutsuAction> { let mut actions = Vec::new(); // Only check during appropriate combat steps if !matches!(game_state.current_phase, Phase::Combat(CombatStep::DeclareBlockers | CombatStep::CombatDamage)) { return actions; } // Get all unblocked attackers controlled by player let available_attackers: Vec<Entity> = unblocked_attackers .iter() .filter(|(_, status, controller)| { controller.0 == player && status.is_unblocked() }) .map(|(entity, _, _)| entity) .collect(); if available_attackers.is_empty() { return actions; } // Find commanders with ninjutsu in command zone for (commander, _, ninjutsu, owner) in commanders.iter() { if owner.0 == player && is_in_command_zone(commander, &game_state.zones) { // For each available attacker, create a potential action for attacker in &available_attackers { actions.push(CommanderNinjutsuAction { commander, unblocked_attacker: *attacker, defending_player: get_defending_player(*attacker, &game_state), cost: ninjutsu.cost.clone(), }); } } } actions } }
Notable Cards with Commander Ninjutsu
Currently, only one card has Commander Ninjutsu:
- Yuriko, the Tiger's Shadow (Commander 2018)
- Commander Ninjutsu {1}{U}{B}
- Whenever Yuriko deals combat damage to a player, reveal the top card of your library and put it into your hand. You lose life equal to that card's mana value.
- Partner With: None
- Color Identity: Blue-Black
Implementation of Yuriko:
#![allow(unused)] fn main() { pub fn create_yuriko() -> impl Bundle { ( CardName("Yuriko, the Tiger's Shadow".to_string()), CardType::Creature, CreatureType(vec!["Human".to_string(), "Ninja".to_string()]), Commander, Power(1), Toughness(3), ManaCost::parse("{1}{U}{B}"), ColorIdentity::parse("{U}{B}"), CommanderNinjutsu { cost: ManaCost::parse("{1}{U}{B}"), }, CombatDamageTriggeredAbility { trigger_condition: CombatDamageCondition::DamageToPlayer, ability: Box::new(YurikoRevealEffect), }, ) } }
Testing Commander Ninjutsu
#![allow(unused)] fn main() { #[test] fn test_commander_ninjutsu_activation() { let mut app = App::new(); app.add_systems(Startup, setup_combat_test); app.add_systems(Update, ( handle_commander_ninjutsu, track_commander_tax, )); // Create test entities let player = app.world.spawn_empty().id(); let opponent = app.world.spawn_empty().id(); // Create Yuriko commander in command zone let yuriko = app.world.spawn(( CardName("Yuriko, the Tiger's Shadow".to_string()), Commander, CommanderNinjutsu { cost: ManaCost::parse("{1}{U}{B}") }, Owner(player), )).id(); // Add to command zone app.world.resource_mut::<Zones>().command.insert(yuriko); // Create an unblocked attacker let attacker = app.world.spawn(( CardName("Ninja of the Deep Hours".to_string()), Controller(player), AttackingStatus { attacking: true, blocked: false, defending_player: Some(opponent), }, )).id(); // Set game state to post-blockers app.world.resource_mut::<GameState>().current_phase = Phase::Combat(CombatStep::DeclareBlockers); // Activate commander ninjutsu app.world.send_event(CommanderNinjutsuEvent { commander: yuriko, returned_creature: attacker, defending_player: opponent, controller: player, }); app.update(); // Verify Yuriko is now on battlefield and attacking let zones = app.world.resource::<Zones>(); assert!(!zones.command.contains(&yuriko)); assert!(zones.battlefield.contains(&yuriko)); let attacking_status = app.world.get::<AttackingStatus>(yuriko).unwrap(); assert!(attacking_status.attacking); assert_eq!(attacking_status.defending_player, Some(opponent)); // Verify the attacker was returned to hand assert!(zones.hand.contains(&attacker)); } }
Related Documentation
- Command Zone: How commanders move to and from the command zone
- Commander Tax: How tax is applied to commanders
- Combat: Combat phase implementation where Ninjutsu is activated
Commander Death Triggers
This document details the implementation of Commander death triggers and related mechanics in the Commander format.
Overview
In Commander, when a commander changes zones from the battlefield to any zone other than the command zone, the commander's owner has the option to move it to the command zone instead. This rule creates a special interaction with "dies" triggers, as the commander technically doesn't die (go to the graveyard) if moved to the command zone.
Rules Evolution
The Commander rules regarding death triggers have evolved over time:
- Pre-2020 Rule: Commanders changing zones would create a replacement effect, preventing them from ever entering the graveyard.
- Post-2020 Rule: Commanders now briefly touch the destination zone before being moved to the command zone as a state-based action, enabling death triggers to work.
Current Rule Implementation
Under the current rules, when a commander would leave the battlefield:
- The commander actually moves to the destination zone (e.g., graveyard)
- This movement triggers any applicable abilities (e.g., "when this creature dies")
- The next time state-based actions are checked, the commander's owner may choose to move it to the command zone
This creates a brief window where the commander exists in the destination zone, allowing "dies" and other zone-change triggers to function normally.
Rules Implementation
Step 1: Zone Transition Tracking
#![allow(unused)] fn main() { /// System to handle initial zone transitions of commanders pub fn track_commander_zone_transitions( mut zone_events: EventReader<ZoneTransitionEvent>, mut pending_commander_moves: ResMut<PendingCommanderMoves>, commander_query: Query<&Commander>, ) { for event in zone_events.read() { // Only care about commanders moving from battlefield if event.from == Zone::Battlefield && event.to != Zone::Command && commander_query.contains(event.entity) { // Record that this commander might move to command zone pending_commander_moves.commanders.insert( event.entity, CommanderMoveInfo { current_zone: event.to, last_transition_time: Instant::now(), } ); } } } }
Step 2: State-Based Action for Command Zone Option
#![allow(unused)] fn main() { /// State-based action system that offers command zone option pub fn commander_zone_choice_sba( mut commands: Commands, mut pending_moves: ResMut<PendingCommanderMoves>, mut zone_transitions: EventWriter<ZoneTransitionEvent>, mut player_choices: EventWriter<PlayerChoiceEvent>, commanders: Query<(Entity, &Owner), With<Commander>>, zones: Res<Zones>, ) { // Review all pending commander moves during state-based action check for (commander, move_info) in pending_moves.commanders.iter() { if let Ok((entity, owner)) = commanders.get(*commander) { // Offer choice to move to command zone player_choices.send(PlayerChoiceEvent { player: owner.0, choice_type: ChoiceType::CommandZoneOption { commander: entity, current_zone: move_info.current_zone, }, timeout: Duration::from_secs(30), }); } } } /// Response handler for command zone choice pub fn handle_command_zone_choice( mut commands: Commands, mut choice_responses: EventReader<PlayerChoiceResponse>, mut zone_transitions: EventWriter<ZoneTransitionEvent>, mut pending_moves: ResMut<PendingCommanderMoves>, ) { for response in choice_responses.read() { if let ChoiceType::CommandZoneOption { commander, current_zone } = &response.choice_type { if response.choice == "command_zone" { // Player chose to move commander to command zone zone_transitions.send(ZoneTransitionEvent { entity: *commander, from: *current_zone, to: Zone::Command, cause: TransitionCause::CommanderRule, }); } // Remove from pending moves either way pending_moves.commanders.remove(commander); } } } }
Step 3: Death Trigger Processing
The death triggers themselves work normally since the commander actually enters the graveyard:
#![allow(unused)] fn main() { /// System that processes death triggers pub fn process_death_triggers( mut commands: Commands, mut zone_events: EventReader<ZoneTransitionEvent>, mut triggered_abilities: EventWriter<TriggeredAbilityEvent>, death_triggers: Query<(Entity, &DiesTriggeredAbility, &Owner)>, ) { for event in zone_events.read() { // Check for dies triggers (battlefield to graveyard) if event.from == Zone::Battlefield && event.to == Zone::Graveyard { if let Ok((entity, ability, owner)) = death_triggers.get(event.entity) { // Trigger the ability triggered_abilities.send(TriggeredAbilityEvent { source: entity, ability_id: ability.id, controller: owner.0, trigger_cause: TriggerCause::ZoneChange { entity, from: event.from, to: event.to, }, }); } } } } }
Timing and Implementation Considerations
The timing of the commander movement is important:
- The death trigger must be processed before the commander moves to the command zone
- The state-based action check that offers the command zone option must happen after death triggers are put on the stack
- The commander stays in the graveyard (or other zone) until the command zone choice is made
#![allow(unused)] fn main() { // System ordering for proper death trigger handling fn build_systems(app: &mut App) { app.add_systems(Update, ( track_commander_zone_transitions, process_death_triggers, apply_state_based_actions, commander_zone_choice_sba ).chain()); } }
Special Interactions
Several cards interact with commander death and zone changes in unique ways:
1. "Dies" Triggers on Commanders
Commanders with "When this creature dies" triggers will function normally:
#![allow(unused)] fn main() { pub fn create_elenda_the_dusk_rose() -> impl Bundle { ( CardName("Elenda, the Dusk Rose".to_string()), // Other card components... Commander, DiesTriggeredAbility { id: AbilityId::new(), effect: Box::new(ElendaDeathEffect), }, ) } }
2. Commanders with Recursion Abilities
Some commanders have abilities that can return them from the graveyard:
#![allow(unused)] fn main() { pub fn create_gisa_and_geralf() -> impl Bundle { ( CardName("Gisa and Geralf".to_string()), // Other card components... Commander, // Ability to cast zombie cards from graveyard ActivatedAbility { id: AbilityId::new(), cost: AbilityCost::None, effect: Box::new(CastZombieFromGraveyardEffect), timing_restriction: TimingRestriction::YourTurn, zone_restriction: ZoneRestriction::OnBattlefield, }, ) } }
3. Graveyard Replacement Effects
Effects that replace going to the graveyard (like Rest in Peace) interact with commanders:
#![allow(unused)] fn main() { pub fn handle_rest_in_peace_effect( mut zone_events: EventReader<ZoneTransitionEvent>, mut modified_zone_events: EventWriter<ModifiedZoneTransitionEvent>, rest_in_peace_effects: Query<&ReplacementEffect>, ) { for event in zone_events.read() { if event.to == Zone::Graveyard { // Check if Rest in Peace or similar effect is active if has_active_graveyard_replacement(&rest_in_peace_effects) { // This will affect commander death triggers modified_zone_events.send(ModifiedZoneTransitionEvent { original: event.clone(), modified_to: Zone::Exile, cause: TransitionCause::ReplacementEffect { effect_name: "Rest in Peace".to_string(), }, }); } } } } }
Commander Death and State-Based Actions
The rules for commander death interact with multiple state-based actions:
#![allow(unused)] fn main() { pub fn apply_state_based_actions( mut commands: Commands, mut game_state: ResMut<GameState>, // ... other queries ) { // Only check when a player would receive priority if !should_check_sba(&game_state) { return; } // Check various state-based actions // ... // Process pending commander moves commander_zone_choice_sba(/* ... */); // Mark that we've checked SBAs game_state.last_sba_check = Instant::now(); } }
User Interface Considerations
The UI for commander death requires special handling:
- Prompt for command zone choice must be clear and timely
- Visual indication of commanders in non-command zones is needed
- Death triggers should be shown clearly when applicable
Testing Commander Death Triggers
#![allow(unused)] fn main() { #[test] fn test_commander_death_triggers() { let mut app = App::new(); app.add_systems(Startup, setup_test); app.add_systems(Update, ( track_commander_zone_transitions, process_death_triggers, commander_zone_choice_sba, handle_command_zone_choice, )); // Create test entities let player = app.world.spawn_empty().id(); // Create a commander with a death trigger let commander = app.world.spawn(( CardName("Test Commander".to_string()), Commander, Owner(player), DiesTriggeredAbility { id: AbilityId::new(), effect: Box::new(TestDeathEffect), }, )).id(); // Move commander from battlefield to graveyard app.world.send_event(ZoneTransitionEvent { entity: commander, from: Zone::Battlefield, to: Zone::Graveyard, cause: TransitionCause::Destroy, }); app.update(); // Verify death trigger happened let triggered = app.world.resource::<TestState>().death_trigger_happened; assert!(triggered, "Death trigger should have happened"); // Choose to move to command zone app.world.send_event(PlayerChoiceResponse { player: player, choice_type: ChoiceType::CommandZoneOption { commander: commander, current_zone: Zone::Graveyard, }, choice: "command_zone".to_string(), }); app.update(); // Verify commander is now in command zone let zones = app.world.resource::<Zones>(); assert!(zones.command.contains(&commander)); assert!(!zones.graveyard.contains(&commander)); } }
Related Documentation
- Command Zone: How the command zone works
- Commander Tax: How commander tax is applied
- State-Based Actions: How state-based actions interact with commander rules
Commander-Specific Cards
This document details the implementation of cards designed specifically for the Commander format.
Overview
Commander has become one of Magic: The Gathering's most popular formats, leading to the creation of cards specifically designed for it. These cards include:
- Cards that explicitly reference the command zone or commanders
- Cards designed to support multiplayer politics
- Cards with mechanics only found in Commander products
- Cards that interact with format-specific rules
Card Categories
Command Zone Interaction Cards
These cards directly interact with the command zone or commanders:
Command Zone Access
#![allow(unused)] fn main() { /// Component for effects that can access the command zone #[derive(Component)] pub struct CommandZoneAccessEffect { /// The type of interaction with the command zone pub interaction_type: CommandZoneInteraction, } /// Types of command zone interactions #[derive(Debug, Clone, PartialEq, Eq)] pub enum CommandZoneInteraction { /// Cast a card from the command zone (e.g., Command Beacon) CastFromZone, /// Return a card to the command zone (e.g., Leadership Vacuum) ReturnToZone, /// Copy a commander (e.g., Sakashima of a Thousand Faces) CopyCommander, /// Modify commander properties (e.g., Nevermore naming a commander) ModifyCommander, } }
Example implementation of Command Beacon:
#![allow(unused)] fn main() { pub fn create_command_beacon() -> impl Bundle { ( CardName("Command Beacon".to_string()), CardType::Land, EntersTapped(false), ActivatedAbility { id: AbilityId::new(), cost: AbilityCost::Sacrifice(SacrificeCost::Self_), effect: Box::new(CommandBeaconEffect), timing_restriction: TimingRestriction::Sorcery, zone_restriction: ZoneRestriction::OnBattlefield, }, ) } /// Implementation of Command Beacon's effect #[derive(Debug, Clone)] pub struct CommandBeaconEffect; impl AbilityEffect for CommandBeaconEffect { fn resolve(&self, world: &mut World, ability_ctx: &mut AbilityContext) { let source = ability_ctx.source; let controller = ability_ctx.controller; // Find commander in command zone if let Some(commander) = find_commander_in_command_zone(world, controller) { // Move commander to hand world.send_event(ZoneTransitionEvent { entity: commander, from: Zone::Command, to: Zone::Hand, cause: TransitionCause::Ability { source, ability_name: "Command Beacon".to_string(), }, }); } } } }
Commander Cost Reduction
Cards that reduce or modify the cost of casting commanders:
#![allow(unused)] fn main() { /// Component for effects that modify commander costs #[derive(Component)] pub struct CommanderCostModifier { /// How the cost is modified pub modification: CostModification, /// Which commanders this applies to (all or specific) pub target: CommanderTarget, } /// Implementation of Emerald Medallion (reduces cost of green spells) pub fn create_emerald_medallion() -> impl Bundle { ( CardName("Emerald Medallion".to_string()), CardType::Artifact, StaticAbility { id: AbilityId::new(), effect: Box::new(ColorCostReductionEffect(Color::Green)), condition: StaticCondition::Always, }, ) } }
Multiplayer Politics Cards
Cards designed for multiplayer political interactions:
#![allow(unused)] fn main() { /// Component for voting cards #[derive(Component)] pub struct VotingMechanic { /// The options players can vote for pub options: Vec<String>, /// What happens based on voting results pub resolution: VoteResolution, } /// Implementation of Council's Judgment pub fn create_councils_judgment() -> impl Bundle { ( CardName("Council's Judgment".to_string()), CardType::Sorcery, ManaCost::parse("{1}{W}{W}"), VotingMechanic { options: vec!["Exile".to_string()], // Each nonland permanent is an option resolution: VoteResolution::ExileMostVotes, }, ) } /// System that handles voting pub fn handle_voting( mut commands: Commands, mut vote_events: EventReader<VoteEvent>, mut vote_results: EventWriter<VoteResultEvent>, voting_cards: Query<(Entity, &VotingMechanic, &Owner)>, players: Query<Entity, With<Player>>, ) { // Implementation details... } }
Commander-Specific Mechanics
Several mechanics were introduced specifically for Commander products:
Lieutenant
Cards with abilities that trigger if you control your commander:
#![allow(unused)] fn main() { /// Component for Lieutenant abilities #[derive(Component)] pub struct Lieutenant { /// The effect that happens when you control your commander pub effect: Box<dyn LieutenantEffect>, } /// Implementation of Thunderfoot Baloth pub fn create_thunderfoot_baloth() -> impl Bundle { ( CardName("Thunderfoot Baloth".to_string()), CardType::Creature, CreatureType(vec!["Beast".to_string()]), Power(5), Toughness(5), Lieutenant { effect: Box::new(ThunderfootBoostEffect), }, ) } /// System that checks for Lieutenant conditions pub fn check_lieutenant_condition( mut commands: Commands, lieutenants: Query<(Entity, &Lieutenant, &Controller)>, commanders: Query<(Entity, &Commander, &Controller)>, mut effect_events: EventWriter<LieutenantEffectEvent>, ) { // For each lieutenant... for (lieutenant_entity, lieutenant, controller) in lieutenants.iter() { // Check if controller controls their commander let controls_commander = commanders .iter() .any(|(_, _, cmd_controller)| cmd_controller.0 == controller.0); // Apply or remove effect based on condition effect_events.send(LieutenantEffectEvent { lieutenant: lieutenant_entity, active: controls_commander, }); } } }
Join Forces
Cards that allow all players to contribute mana to a spell:
#![allow(unused)] fn main() { /// Component for Join Forces mechanic #[derive(Component)] pub struct JoinForces { /// What happens based on mana contributed pub effect: Box<dyn JoinForcesEffect>, /// Which players can contribute pub contributor_restriction: ContributorRestriction, } /// Implementation of Minds Aglow pub fn create_minds_aglow() -> impl Bundle { ( CardName("Minds Aglow".to_string()), CardType::Sorcery, ManaCost::parse("{U}"), JoinForces { effect: Box::new(MindsAglowDrawEffect), contributor_restriction: ContributorRestriction::All, }, ) } }
Monarch
The Monarch mechanic introduces a special designation that players can claim, providing card advantage:
#![allow(unused)] fn main() { /// Component marking a player as the Monarch #[derive(Component)] pub struct Monarch; /// Resource tracking the current monarch #[derive(Resource)] pub struct MonarchState { /// The current monarch, if any pub current_monarch: Option<Entity>, /// When the monarchy was last changed pub last_changed: Option<f32>, } /// System that handles the Monarch end-of-turn trigger pub fn monarch_end_step_trigger( time: Res<Time>, monarch_state: Res<MonarchState>, mut turn_events: EventReader<EndStepEvent>, mut card_draw_events: EventWriter<DrawCardEvent>, ) { for event in turn_events.read() { if let Some(monarch) = monarch_state.current_monarch { if event.player == monarch { // Monarch draws a card at end of their turn card_draw_events.send(DrawCardEvent { player: monarch, amount: 1, source: None, reason: DrawReason::Ability("Monarch".to_string()), }); } } } } /// System that transfers the Monarch when combat damage is dealt to them pub fn monarch_combat_damage_transfer( mut commands: Commands, mut monarch_state: ResMut<MonarchState>, mut combat_damage_events: EventReader<CombatDamageEvent>, players: Query<Entity, With<Player>>, ) { for event in combat_damage_events.read() { if let Some(current_monarch) = monarch_state.current_monarch { if event.defending_player == current_monarch { // Transfer monarchy to attacking player if let Some(monarch_component) = commands.get_entity(current_monarch) { monarch_component.remove::<Monarch>(); } if let Some(new_monarch) = commands.get_entity(event.attacking_player_controller) { new_monarch.insert(Monarch); monarch_state.current_monarch = Some(event.attacking_player_controller); monarch_state.last_changed = Some(time.elapsed_seconds()); } } } } } }
Myriad
Myriad creates token copies attacking each opponent:
#![allow(unused)] fn main() { /// Component for the Myriad ability #[derive(Component)] pub struct Myriad; /// System that handles Myriad triggers pub fn handle_myriad_attacks( mut commands: Commands, mut declare_attacker_events: EventReader<DeclareAttackerEvent>, myriad_creatures: Query<Entity, With<Myriad>>, players: Query<Entity, With<Player>>, controllers: Query<&Controller>, ) { for event in declare_attacker_events.read() { if myriad_creatures.contains(event.attacker) { let attacker_controller = controllers.get(event.attacker).map(|c| c.0).unwrap_or_default(); // Create token copies attacking each other opponent for potential_defender in players.iter() { // Skip the attacking player and the already-targeted defender if potential_defender == attacker_controller || potential_defender == event.defender { continue; } // Create a token copy attacking this opponent let token = commands.spawn(( // Copy relevant components from original // Add attacking status to new opponent AttackingStatus { defending_player: potential_defender, }, TokenCopy { original: event.attacker, // Myriad tokens are exiled at end of combat exile_at_end_of_combat: true, }, )).id(); // More token setup... } } } } }
Melee
Melee gives a bonus based on how many opponents were attacked:
#![allow(unused)] fn main() { /// Component for the Melee ability #[derive(Component)] pub struct Melee { /// Bonus per opponent attacked pub bonus: i32, } /// System that calculates Melee bonuses pub fn calculate_melee_bonuses( mut commands: Commands, mut declare_attackers_step_events: EventReader<DeclareAttackersStepEvent>, melee_creatures: Query<(Entity, &Melee, &Controller)>, mut attacking_status: Query<&AttackingStatus>, players: Query<Entity, With<Player>>, ) { for event in declare_attackers_step_events.read() { // For each creature with Melee... for (melee_entity, melee, controller) in melee_creatures.iter() { // Count distinct opponents attacked let opponents_attacked = attacking_status .iter() .filter(|status| { // Only count attacks from controller's creatures if let Ok(attacker_controller) = controllers.get(status.attacker) { attacker_controller.0 == controller.0 && status.defending_player != controller.0 } else { false } }) .map(|status| status.defending_player) .collect::<HashSet<Entity>>() .len(); // Apply Melee bonus based on opponents attacked commands.entity(melee_entity).insert(MeleeBoost { power_bonus: melee.bonus * opponents_attacked as i32, toughness_bonus: melee.bonus * opponents_attacked as i32, expires_at: ExpiryTiming::EndOfTurn, }); } } } }
Goad
Goad forces creatures to attack players other than you:
#![allow(unused)] fn main() { /// Component marking a creature as Goaded #[derive(Component)] pub struct Goaded { /// The player who applied the goad effect pub goaded_by: Entity, /// When the goad effect expires pub expires_at: ExpiryTiming, } /// System that enforces Goad restrictions during attacks pub fn enforce_goad_restrictions( goaded_creatures: Query<(Entity, &Goaded)>, mut attack_validation_events: EventReader<ValidateAttackEvent>, mut attack_response_events: EventWriter<AttackValidationResponse>, ) { for event in attack_validation_events.read() { if let Ok((_, goaded)) = goaded_creatures.get(event.attacker) { // If attacking the player who goaded this creature, attack is invalid if event.defender == goaded.goaded_by { attack_response_events.send(AttackValidationResponse { attacker: event.attacker, defender: event.defender, is_valid: false, reason: "This creature is goaded and must attack a different player if able".to_string(), }); } } } } /// System that enforces Goad requirements to attack if able pub fn enforce_goad_attack_requirement( goaded_creatures: Query<(Entity, &Goaded, &CanAttack, &Controller)>, mut attack_requirement_events: EventReader<AttackRequirementCheckEvent>, mut attack_required_events: EventWriter<AttackRequiredEvent>, players: Query<Entity, With<Player>>, ) { for event in attack_requirement_events.read() { for (goaded_entity, goaded, can_attack, controller) in goaded_creatures.iter() { // If creature can attack and is controlled by current player if can_attack.0 && controller.0 == event.attacking_player { // Find valid attack targets (not the player who goaded) let valid_targets: Vec<Entity> = players .iter() .filter(|player| *player != goaded.goaded_by && *player != controller.0) .collect(); // If there are valid targets, this creature must attack if !valid_targets.is_empty() { attack_required_events.send(AttackRequiredEvent { creature: goaded_entity, valid_targets, }); } } } } } }
Commander-Specific Cycles and Card Groups
Medallion Cycle
The Medallion cycle (Ruby Medallion, Sapphire Medallion, etc.) reduces costs for spells of specific colors:
#![allow(unused)] fn main() { pub fn create_ruby_medallion() -> impl Bundle { ( CardName("Ruby Medallion".to_string()), CardType::Artifact, StaticAbility { id: AbilityId::new(), effect: Box::new(ColorCostReductionEffect(Color::Red)), condition: StaticCondition::Always, }, ) } }
Commander's Plate
A special equipment that gives protection from colors outside your commander's color identity:
#![allow(unused)] fn main() { pub fn create_commanders_plate() -> impl Bundle { ( CardName("Commander's Plate".to_string()), CardType::Artifact, ArtifactType(vec!["Equipment".to_string()]), EquipCost(ManaCost::parse("{3}")), StaticAbility { id: AbilityId::new(), effect: Box::new(CommandersPlateEffect), condition: StaticCondition::IsEquipped, }, ) } #[derive(Debug, Clone)] pub struct CommandersPlateEffect; impl StaticEffect for CommandersPlateEffect { fn apply(&self, world: &mut World, source: Entity, target: Entity) { // Get equipped creature's controller let controller = world.get::<Controller>(target).unwrap().0; // Get controller's commanders' color identity let commander_identity = get_commander_color_identity(world, controller); // Grant protection from colors outside that identity let protection_colors = ALL_COLORS.iter() .filter(|color| !commander_identity.contains(**color)) .copied() .collect(); world.entity_mut(target).insert(Protection { protection_from: ProtectionType::Colors(protection_colors), source, }); // Also grant stat boosts world.entity_mut(target).insert(StatBoost { power: 3, toughness: 3, source, }); } } }
Testing Commander-Specific Cards
Testing commander-specific cards requires special test fixtures and scenarios:
#![allow(unused)] fn main() { #[test] fn test_command_beacon() { let mut app = App::new(); setup_test_game(&mut app); let player = app.world.spawn(Player).id(); // Create a commander with tax let commander = app.world.spawn(( CardName("Test Commander".to_string()), Commander, InCommandZone(player), CommanderCastCount(2), // Commander has been cast twice before )).id(); // Create Command Beacon let beacon = app.world.spawn(create_command_beacon()).id(); // Use Command Beacon's ability use_ability(beacon, player, &mut app.world); // Verify commander moved to hand assert!(has_component::<InHand>(&app.world, commander)); assert!(!has_component::<InCommandZone>(&app.world, commander)); // Verify commander can be cast without tax let cast_cost = calculate_commander_cast_cost(&app.world, commander, player); assert_eq!(cast_cost, commander_base_cost(&app.world, commander)); } }
UI Considerations
Commander-specific cards require special UI treatment:
- Command zone interactions need visual clarity
- Political mechanics need multiplayer-aware UI elements
- Special effects like Monarch need distinctive visual indicators
#![allow(unused)] fn main() { /// UI component for displaying monarchy status #[derive(Component)] pub struct MonarchyIndicator { pub entity: Entity, } /// System to update monarchy UI pub fn update_monarchy_ui( monarch_state: Res<MonarchState>, mut indicators: Query<(&mut Visibility, &MonarchyIndicator)>, ) { for (mut visibility, indicator) in indicators.iter_mut() { visibility.is_visible = monarch_state.current_monarch == Some(indicator.entity); } } }
Commander Preconstructed Deck Integration
Our system includes support for preconstructed Commander decks:
#![allow(unused)] fn main() { /// Resource containing preconstructed Commander deck definitions #[derive(Resource)] pub struct PreconstructedDecks { pub decks: HashMap<String, PreconstructedDeckData>, } /// Data for a preconstructed Commander deck #[derive(Clone, Debug)] pub struct PreconstructedDeckData { /// Deck name pub name: String, /// The set/product the deck is from pub product: String, /// Year released pub year: u32, /// Primary commander pub commander: String, /// Secondary commander/partner (if any) pub partner: Option<String>, /// List of all cards in the deck pub card_list: Vec<String>, /// Deck color identity pub color_identity: ColorIdentity, /// Deck theme or strategy pub theme: String, } /// Function to load all preconstructed Commander decks pub fn load_preconstructed_decks() -> PreconstructedDecks { // Load deck definitions from files // ... } }
Conclusion
Commander-specific cards are a vital part of the format's identity. By implementing these cards correctly, we ensure that our game engine provides an authentic Commander experience. The implementation must balance rules accuracy with performance considerations, especially for complex political mechanics in multiplayer games.
Related Resources
Multiplayer Politics Tests
This section contains tests related to the multiplayer politics mechanics in the Commander format.
Tests
Deal Testing
Tests for mechanics that involve deals and agreements between players, including:
- Promise effects ("I'll do X if you do Y")
- Tempting offer cards
- Consequence tracking for deal violations
- UI/UX for deal making
Monarch Testing
Tests for the Monarch mechanic, including:
- Monarch status transitions between players
- End-of-turn triggers for the Monarch
- Combat interactions with the Monarch status
- Multiple Monarch-granting effects
Voting Testing
Tests for the Council's Dilemma and other voting mechanics, including:
- Vote counting
- Vote option resolution
- Tie-breaking
- Effects that modify voting
These tests ensure that the political elements unique to multiplayer Commander function correctly within the game engine.
Deal System Testing
Overview
This document details the testing approach for the Deal system within Rummage's multiplayer Commander implementation. Deals are a key political mechanic allowing players to make formal agreements with benefits and consequences, adding strategic depth to multiplayer interaction.
Deal Creation Tests
Tests for the creation and initialization of deals:
#![allow(unused)] fn main() { #[test] fn test_deal_creation() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin); // Setup players let players = setup_four_player_game(&mut app); // Create a simple deal let deal_id = app.world.send_event(CreateDealEvent { proposer: players[0], target: players[1], terms: vec![ DealTerm::AttackRestriction { restricted_player: players[1], duration: DealDuration::Turns(2), }, DealTerm::NonAggressionPact { duration: DealDuration::Turns(2), }, ], rewards: vec![ DealReward::DrawCards { count: 1 }, ], penalties: vec![ DealPenalty::LifeLoss { amount: 5 }, ], duration: DealDuration::Turns(2), }); app.update(); // Verify deal was created and is in pending state let deal_registry = app.world.resource::<DealRegistry>(); let deal = deal_registry.get_deal(deal_id).unwrap(); assert_eq!(deal.proposer, players[0]); assert_eq!(deal.target, players[1]); assert_eq!(deal.terms.len(), 2); assert_eq!(deal.status, DealStatus::Pending); } #[test] fn test_deal_term_validation() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin); // Setup players let players = setup_four_player_game(&mut app); // Create a deal with invalid terms (can't restrict a player not in the deal) let invalid_deal_id = app.world.send_event(CreateDealEvent { proposer: players[0], target: players[1], terms: vec![ DealTerm::AttackRestriction { restricted_player: players[2], // Invalid: players[2] not part of deal duration: DealDuration::Turns(2), }, ], rewards: vec![], penalties: vec![], duration: DealDuration::Turns(2), }); app.update(); // Verify deal was rejected let deal_registry = app.world.resource::<DealRegistry>(); let invalid_deal = deal_registry.get_deal(invalid_deal_id).unwrap(); assert_eq!(invalid_deal.status, DealStatus::Rejected); assert_eq!( invalid_deal.rejection_reason, Some(DealRejectionReason::InvalidTerms) ); // Create a valid deal let valid_deal_id = app.world.send_event(CreateDealEvent { proposer: players[0], target: players[1], terms: vec![ DealTerm::AttackRestriction { restricted_player: players[1], // Valid: players[1] is target duration: DealDuration::Turns(2), }, ], rewards: vec![], penalties: vec![], duration: DealDuration::Turns(2), }); app.update(); // Verify valid deal is pending let valid_deal = deal_registry.get_deal(valid_deal_id).unwrap(); assert_eq!(valid_deal.status, DealStatus::Pending); } #[test] fn test_deal_duration_validation() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin); // Setup players let players = setup_four_player_game(&mut app); // Create a deal with excessively long duration let long_deal_id = app.world.send_event(CreateDealEvent { proposer: players[0], target: players[1], terms: vec![ DealTerm::NonAggressionPact { duration: DealDuration::Turns(20), // Too long }, ], rewards: vec![], penalties: vec![], duration: DealDuration::Turns(20), // Exceeds maximum allowed duration }); app.update(); // Verify deal was rejected let deal_registry = app.world.resource::<DealRegistry>(); let long_deal = deal_registry.get_deal(long_deal_id).unwrap(); assert_eq!(long_deal.status, DealStatus::Rejected); assert_eq!( long_deal.rejection_reason, Some(DealRejectionReason::ExcessiveDuration) ); } }
Deal Negotiation Tests
Testing the deal negotiation mechanics:
#![allow(unused)] fn main() { #[test] fn test_deal_acceptance() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin); // Setup players let players = setup_four_player_game(&mut app); // Create a deal let deal_id = app.world.send_event(CreateDealEvent { proposer: players[0], target: players[1], terms: vec![ DealTerm::NonAggressionPact { duration: DealDuration::Turns(2), }, ], rewards: vec![ DealReward::DrawCards { count: 1 }, ], penalties: vec![], duration: DealDuration::Turns(2), }); app.update(); // Target accepts the deal app.world.send_event(RespondToDealEvent { responder: players[1], deal_id, response: DealResponse::Accept, }); app.update(); // Verify deal is now active let deal_registry = app.world.resource::<DealRegistry>(); let deal = deal_registry.get_deal(deal_id).unwrap(); assert_eq!(deal.status, DealStatus::Active); // Verify reward was given let player1_hand_size = get_player_hand_size(&app, players[1]); assert_eq!(player1_hand_size, 8); // Assuming starting hand size of 7 + 1 from reward } #[test] fn test_deal_rejection() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin); // Setup players let players = setup_four_player_game(&mut app); // Create a deal let deal_id = app.world.send_event(CreateDealEvent { proposer: players[0], target: players[1], terms: vec![ DealTerm::NonAggressionPact { duration: DealDuration::Turns(2), }, ], rewards: vec![], penalties: vec![], duration: DealDuration::Turns(2), }); app.update(); // Target rejects the deal app.world.send_event(RespondToDealEvent { responder: players[1], deal_id, response: DealResponse::Reject, }); app.update(); // Verify deal is now rejected let deal_registry = app.world.resource::<DealRegistry>(); let deal = deal_registry.get_deal(deal_id).unwrap(); assert_eq!(deal.status, DealStatus::Rejected); assert_eq!(deal.rejection_reason, Some(DealRejectionReason::TargetRejected)); } #[test] fn test_deal_counter_proposal() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin); // Setup players let players = setup_four_player_game(&mut app); // Create a deal let deal_id = app.world.send_event(CreateDealEvent { proposer: players[0], target: players[1], terms: vec![ DealTerm::AttackRestriction { restricted_player: players[1], duration: DealDuration::Turns(3), }, ], rewards: vec![ DealReward::DrawCards { count: 1 }, ], penalties: vec![], duration: DealDuration::Turns(3), }); app.update(); // Target makes a counter-proposal let counter_proposal = DealCounterProposal { terms: vec![ DealTerm::AttackRestriction { restricted_player: players[1], duration: DealDuration::Turns(2), // Shorter duration }, ], rewards: vec![ DealReward::DrawCards { count: 2 }, // More cards ], penalties: vec![], duration: DealDuration::Turns(2), // Shorter duration }; app.world.send_event(RespondToDealEvent { responder: players[1], deal_id, response: DealResponse::CounterProposal(counter_proposal), }); app.update(); // Verify original deal is now countered let deal_registry = app.world.resource::<DealRegistry>(); let original_deal = deal_registry.get_deal(deal_id).unwrap(); assert_eq!(original_deal.status, DealStatus::Countered); // Find the counter proposal let counter_deals: Vec<_> = deal_registry.deals_iter() .filter(|d| d.status == DealStatus::Pending && d.proposer == players[1] && d.target == players[0]) .collect(); assert_eq!(counter_deals.len(), 1); let counter_deal = &counter_deals[0]; // Verify counter deal has the modified terms assert_eq!(counter_deal.rewards.len(), 1); if let DealReward::DrawCards { count } = counter_deal.rewards[0] { assert_eq!(count, 2); } else { panic!("Expected DrawCards reward"); } // Proposer accepts counter-proposal app.world.send_event(RespondToDealEvent { responder: players[0], deal_id: counter_deal.id, response: DealResponse::Accept, }); app.update(); // Verify counter deal is now active let updated_counter_deal = deal_registry.get_deal(counter_deal.id).unwrap(); assert_eq!(updated_counter_deal.status, DealStatus::Active); } }
Deal Enforcement Tests
Testing the enforcement and violation of deals:
#![allow(unused)] fn main() { #[test] fn test_deal_enforcement_attack_restriction() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin) .add_plugin(CombatPlugin); // Setup players let players = setup_four_player_game(&mut app); // Create player creatures let attacker = app.world.spawn(( Creature::default(), Permanent { controller: players[1], ..Default::default() }, )).id(); // Create a deal with attack restriction let deal_id = app.world.send_event(CreateDealEvent { proposer: players[0], target: players[1], terms: vec![ DealTerm::AttackRestriction { restricted_player: players[0], // Target can't attack proposer duration: DealDuration::Turns(2), }, ], rewards: vec![], penalties: vec![ DealPenalty::LifeLoss { amount: 3 }, ], duration: DealDuration::Turns(2), }); app.update(); // Target accepts the deal app.world.send_event(RespondToDealEvent { responder: players[1], deal_id, response: DealResponse::Accept, }); app.update(); // Capture life total before violation let initial_life = app.world.get::<Player>(players[1]).unwrap().life_total; // Target attempts to attack proposer (violating deal) app.world.send_event(DeclareAttackerEvent { attacker, defender: players[0], }); app.update(); // Verify deal violation was detected let deal_registry = app.world.resource::<DealRegistry>(); let deal = deal_registry.get_deal(deal_id).unwrap(); assert!(deal.violations.contains(&DealViolation { violator: players[1], violation_type: ViolationType::AttackRestriction, turn: app.world.resource::<TurnState>().turn_number, })); // Verify penalty was applied let new_life = app.world.get::<Player>(players[1]).unwrap().life_total; assert_eq!(new_life, initial_life - 3); } #[test] fn test_deal_expiration() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin) .add_plugin(TurnStructurePlugin); // Setup players let players = setup_four_player_game(&mut app); // Create a deal with short duration let deal_id = app.world.send_event(CreateDealEvent { proposer: players[0], target: players[1], terms: vec![ DealTerm::NonAggressionPact { duration: DealDuration::Turns(1), }, ], rewards: vec![], penalties: vec![], duration: DealDuration::Turns(1), // 1 turn duration }); app.update(); // Target accepts the deal app.world.send_event(RespondToDealEvent { responder: players[1], deal_id, response: DealResponse::Accept, }); app.update(); // Verify deal is active let deal_registry = app.world.resource::<DealRegistry>(); let deal_before = deal_registry.get_deal(deal_id).unwrap(); assert_eq!(deal_before.status, DealStatus::Active); // Advance turn advance_turn(&mut app); app.update(); // Verify deal is now expired let deal_registry = app.world.resource::<DealRegistry>(); let deal_after = deal_registry.get_deal(deal_id).unwrap(); assert_eq!(deal_after.status, DealStatus::Expired); } #[test] fn test_deal_auto_termination_on_player_loss() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin); // Setup players let players = setup_four_player_game(&mut app); // Create and activate a deal let deal_id = create_and_accept_deal(&mut app, players[0], players[1]); // Verify deal is active let deal_registry = app.world.resource::<DealRegistry>(); let deal_before = deal_registry.get_deal(deal_id).unwrap(); assert_eq!(deal_before.status, DealStatus::Active); // Player leaves game app.world.send_event(PlayerEliminatedEvent { player: players[0] }); app.update(); // Verify deal is now terminated let deal_registry = app.world.resource::<DealRegistry>(); let deal_after = deal_registry.get_deal(deal_id).unwrap(); assert_eq!(deal_after.status, DealStatus::Terminated); assert_eq!( deal_after.termination_reason, Some(DealTerminationReason::PlayerEliminated) ); } }
Deal History and Reputation Tests
Testing deal history tracking and reputation systems:
#![allow(unused)] fn main() { #[test] fn test_deal_history_tracking() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin); // Setup players let players = setup_four_player_game(&mut app); // Create and activate multiple deals let deal1_id = create_and_accept_deal(&mut app, players[0], players[1]); let deal2_id = create_and_accept_deal(&mut app, players[1], players[2]); let deal3_id = create_and_accept_deal(&mut app, players[0], players[2]); // Player 2 violates deal with player 0 simulate_deal_violation(&mut app, deal3_id, players[2]); // Get deal history let history = app.world.resource::<DealHistory>(); // Check player 0's history let player0_history = history.get_player_history(players[0]); assert_eq!(player0_history.deals_proposed, 2); assert_eq!(player0_history.deals_honored, 2); // Both active or not violated yet // Check player 2's history let player2_history = history.get_player_history(players[2]); assert_eq!(player2_history.deals_proposed, 0); assert_eq!(player2_history.deals_accepted, 2); assert_eq!(player2_history.deals_violated, 1); } #[test] fn test_reputation_system() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin); // Setup players let players = setup_four_player_game(&mut app); // Create and accept several deals let deal1_id = create_and_accept_deal(&mut app, players[0], players[1]); let deal2_id = create_and_accept_deal(&mut app, players[0], players[2]); let deal3_id = create_and_accept_deal(&mut app, players[0], players[3]); // Player 3 violates deal simulate_deal_violation(&mut app, deal3_id, players[3]); // Check reputations let reputation_system = app.world.resource::<ReputationSystem>(); let player0_rep = reputation_system.get_reputation(players[0]); let player1_rep = reputation_system.get_reputation(players[1]); let player3_rep = reputation_system.get_reputation(players[3]); // Player 0 should have good reputation (keeps deals) assert!(player0_rep.score > 0.0); // Player 1 should have neutral/positive reputation (honors deals) assert!(player1_rep.score >= 0.0); // Player 3 should have negative reputation (violated deal) assert!(player3_rep.score < 0.0); // Test reputation effects on deal proposals // Player 3 (bad reputation) tries to make a deal let deal4_id = app.world.send_event(CreateDealEvent { proposer: players[3], target: players[1], terms: vec![ DealTerm::NonAggressionPact { duration: DealDuration::Turns(2), }, ], rewards: vec![], penalties: vec![], duration: DealDuration::Turns(2), }); app.update(); // Target is more likely to reject due to proposer's reputation let ai_decision = app.world.resource::<AiDealSystem>() .evaluate_deal_proposal(players[1], deal4_id); // AI should factor in reputation (exact values depend on implementation) assert!(ai_decision.trust_factor < 0.5); } }
Integration Tests
Testing deal system integration with other game systems:
#![allow(unused)] fn main() { #[test] fn test_deal_integration_with_combat() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin) .add_plugin(CombatPlugin); // Setup players and creatures let players = setup_four_player_game(&mut app); let creature1 = spawn_test_creature(&mut app, 2, 2, players[0]); let creature2 = spawn_test_creature(&mut app, 3, 3, players[1]); // Create non-aggression pact let deal_id = app.world.send_event(CreateDealEvent { proposer: players[0], target: players[1], terms: vec![ DealTerm::NonAggressionPact { duration: DealDuration::Turns(2), }, ], rewards: vec![], penalties: vec![], duration: DealDuration::Turns(2), }); app.update(); // Accept deal app.world.send_event(RespondToDealEvent { responder: players[1], deal_id, response: DealResponse::Accept, }); app.update(); // Try to declare attack between deal participants app.world.send_event(DeclareAttackerEvent { attacker: creature1, defender: players[1], }); app.update(); // Verify combat restriction was enforced let combat_state = app.world.resource::<CombatState>(); assert!(!combat_state.is_attacking(creature1)); // But can still attack other players app.world.send_event(DeclareAttackerEvent { attacker: creature1, defender: players[2], // Not part of deal }); app.update(); // Verify attack was allowed let combat_state = app.world.resource::<CombatState>(); assert!(combat_state.is_attacking(creature1)); assert_eq!(combat_state.get_defender(creature1), Some(players[2])); } #[test] fn test_deal_integration_with_card_effects() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin); // Setup players let players = setup_four_player_game(&mut app); // Create "deal breaker" card let deal_breaker = app.world.spawn(( Card::default(), DealBreakerEffect, )).id(); // Create and accept a deal let deal_id = create_and_accept_deal(&mut app, players[0], players[1]); // Verify deal is active let deal_registry = app.world.resource::<DealRegistry>(); let deal_before = deal_registry.get_deal(deal_id).unwrap(); assert_eq!(deal_before.status, DealStatus::Active); // Cast deal breaker card (e.g., "Council's Judgment") app.world.send_event(CastCardEvent { caster: players[2], card: deal_breaker, targets: vec![EntityTarget::Deal(deal_id)], }); app.update(); // Resolve card effect app.world.send_event(ResolveCardEffectEvent { card: deal_breaker, }); app.update(); // Verify deal was terminated let deal_registry = app.world.resource::<DealRegistry>(); let deal_after = deal_registry.get_deal(deal_id).unwrap(); assert_eq!(deal_after.status, DealStatus::Terminated); assert_eq!( deal_after.termination_reason, Some(DealTerminationReason::CardEffect) ); } }
UI and Notification Tests
Testing UI and notification aspects of the deal system:
#![allow(unused)] fn main() { #[test] fn test_deal_ui_representation() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin) .add_plugin(UiPlugin); // Setup players let players = setup_four_player_game(&mut app); // Create a deal let deal_id = app.world.send_event(CreateDealEvent { proposer: players[0], target: players[1], terms: vec![ DealTerm::NonAggressionPact { duration: DealDuration::Turns(2), }, ], rewards: vec![], penalties: vec![], duration: DealDuration::Turns(2), }); app.update(); // Verify deal UI elements were created let deal_ui_elements = app.world.query_filtered::<Entity, With<DealUiElement>>() .iter(&app.world) .collect::<Vec<_>>(); assert!(!deal_ui_elements.is_empty()); // Check for proposal notification let notifications = app.world.query_filtered::<&Notification, With<DealProposalNotification>>() .iter(&app.world) .collect::<Vec<_>>(); assert!(!notifications.is_empty()); assert!(notifications[0].message.contains("proposed a deal")); // Accept deal app.world.send_event(RespondToDealEvent { responder: players[1], deal_id, response: DealResponse::Accept, }); app.update(); // Check for acceptance notification let acceptance_notifications = app.world.query_filtered::<&Notification, With<DealAcceptanceNotification>>() .iter(&app.world) .collect::<Vec<_>>(); assert!(!acceptance_notifications.is_empty()); assert!(acceptance_notifications[0].message.contains("accepted")); // Verify active deal indicator visible let active_deal_indicators = app.world.query_filtered::<Entity, (With<ActiveDealIndicator>, With<Parent>)>() .iter(&app.world) .collect::<Vec<_>>(); assert!(!active_deal_indicators.is_empty()); } #[test] fn test_deal_violation_notifications() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin) .add_plugin(UiPlugin); // Setup players let players = setup_four_player_game(&mut app); // Create and accept a deal let deal_id = create_and_accept_deal(&mut app, players[0], players[1]); // Simulate deal violation simulate_deal_violation(&mut app, deal_id, players[1]); // Check for violation notification let violation_notifications = app.world.query_filtered::<&Notification, With<DealViolationNotification>>() .iter(&app.world) .collect::<Vec<_>>(); assert!(!violation_notifications.is_empty()); assert!(violation_notifications[0].message.contains("violated")); } }
Helper Functions
#![allow(unused)] fn main() { /// Creates a standard deal and accepts it fn create_and_accept_deal(app: &mut App, proposer: Entity, target: Entity) -> DealId { // Create deal let deal_id = app.world.send_event(CreateDealEvent { proposer, target, terms: vec![ DealTerm::NonAggressionPact { duration: DealDuration::Turns(2), }, ], rewards: vec![], penalties: vec![], duration: DealDuration::Turns(2), }); app.update(); // Accept deal app.world.send_event(RespondToDealEvent { responder: target, deal_id, response: DealResponse::Accept, }); app.update(); deal_id } /// Simulates violating a deal fn simulate_deal_violation(app: &mut App, deal_id: DealId, violator: Entity) { // Get deal let deal_registry = app.world.resource::<DealRegistry>(); let deal = deal_registry.get_deal(deal_id).unwrap(); // Determine violation type based on terms let violation_type = if let Some(DealTerm::NonAggressionPact { .. }) = deal.terms.first() { ViolationType::NonAggressionViolation } else if let Some(DealTerm::AttackRestriction { .. }) = deal.terms.first() { ViolationType::AttackRestriction } else { ViolationType::Generic }; // Trigger violation app.world.send_event(DealViolationEvent { deal_id, violator, violation_type, }); app.update(); } /// Creates a test creature fn spawn_test_creature(app: &mut App, power: i32, toughness: i32, controller: Entity) -> Entity { app.world.spawn(( Creature { power, toughness, ..Default::default() }, Permanent { controller, ..Default::default() }, )).id() } }
Conclusion
The Deal System adds significant depth to political interactions in Commander. These tests ensure that deals are properly created, negotiated, enforced, and terminated under all relevant circumstances, while accurately tracking player reputation based on deal history.
The Monarch Mechanic Testing
Overview
This document details the testing approach for the Monarch game mechanic within the Commander format. The Monarch is a special designation that can be passed between players and provides card advantage through its "draw a card at the beginning of your end step" effect.
Test Components
Monarch Assignment Tests
Testing the ways a player can become the Monarch:
#![allow(unused)] fn main() { #[test] fn test_initial_monarch_assignment() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin); // Create a test player let player = app.world.spawn(Player::default()).id(); // Assign monarch to player app.world.send_event(AssignMonarchEvent { player }); app.update(); // Verify player is now the monarch let monarch = app.world.resource::<MonarchState>(); assert_eq!(monarch.current_monarch, Some(player)); } #[test] fn test_monarch_assignment_via_card_effect() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin); // Create test players let player1 = app.world.spawn(Player::default()).id(); let player2 = app.world.spawn(Player::default()).id(); // Create a monarch-granting card (e.g., "Court of Grace") let monarch_card = app.world.spawn(( Card::default(), MonarchGrantingComponent, )).id(); // Assign to player1 give_card_to_player(&mut app, monarch_card, player1); // Cast the card cast_card(&mut app, player1, monarch_card); app.update(); // Verify player1 is now the monarch let monarch = app.world.resource::<MonarchState>(); assert_eq!(monarch.current_monarch, Some(player1)); // Test changing the monarch to player2 app.world.send_event(AssignMonarchEvent { player: player2 }); app.update(); // Verify player2 is now the monarch let monarch = app.world.resource::<MonarchState>(); assert_eq!(monarch.current_monarch, Some(player2)); } }
Combat-Based Monarch Transfer Tests
Test monarch transfer through combat damage:
#![allow(unused)] fn main() { #[test] fn test_monarch_transfer_through_combat() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin) .add_plugin(CombatPlugin); // Create test players let player1 = app.world.spawn(Player::default()).id(); let player2 = app.world.spawn(Player::default()).id(); // Make player1 the monarch app.world.send_event(AssignMonarchEvent { player: player1 }); app.update(); // Create creature controlled by player2 let creature = app.world.spawn(( Creature::default(), Permanent { controller: player2, ..Default::default() }, )).id(); // Simulate combat damage to player1 app.world.send_event(CombatDamageEvent { source: creature, target: player1, amount: 2, }); app.update(); // Verify monarch transferred to player2 let monarch = app.world.resource::<MonarchState>(); assert_eq!(monarch.current_monarch, Some(player2)); } }
Monarch Effect Tests
Testing the effects of being the monarch:
#![allow(unused)] fn main() { #[test] fn test_monarch_card_draw_effect() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin) .add_plugin(TurnStructurePlugin); // Create test player let player = app.world.spawn(Player::default()).id(); // Make player the monarch app.world.send_event(AssignMonarchEvent { player }); app.update(); // Get initial hand size let initial_hand_size = get_player_hand_size(&app, player); // Simulate end step for monarch app.world.resource_mut::<TurnState>().current_player = player; app.world.resource_mut::<TurnState>().current_phase = Phase::End; app.update(); // Trigger end step effects app.world.send_event(PhaseEndEvent { phase: Phase::End }); app.update(); // Verify player drew a card let new_hand_size = get_player_hand_size(&app, player); assert_eq!(new_hand_size, initial_hand_size + 1); } }
Edge Case Tests
Testing edge cases and unusual interactions:
#![allow(unused)] fn main() { #[test] fn test_monarch_persistence_across_turns() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin) .add_plugin(TurnStructurePlugin); // Setup multi-player game let players = setup_four_player_game(&mut app); // Make first player the monarch app.world.send_event(AssignMonarchEvent { player: players[0] }); app.update(); // Simulate full turn cycle for _ in 0..4 { advance_turn(&mut app); } // Verify player is still monarch after full cycle let monarch = app.world.resource::<MonarchState>(); assert_eq!(monarch.current_monarch, Some(players[0])); } #[test] fn test_monarch_when_player_leaves_game() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin); // Setup multi-player game let players = setup_four_player_game(&mut app); // Make player 1 the monarch app.world.send_event(AssignMonarchEvent { player: players[0] }); app.update(); // Player leaves game app.world.send_event(PlayerLeftGameEvent { player: players[0] }); app.update(); // Verify monarch is reset let monarch = app.world.resource::<MonarchState>(); assert_eq!(monarch.current_monarch, None); // Verify next player can become monarch app.world.send_event(AssignMonarchEvent { player: players[1] }); app.update(); let monarch = app.world.resource::<MonarchState>(); assert_eq!(monarch.current_monarch, Some(players[1])); } }
Multiple Monarch-Granting Effect Tests
Testing what happens when multiple effects attempt to assign the monarch:
#![allow(unused)] fn main() { #[test] fn test_simultaneous_monarch_granting_effects() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin); // Setup players let player1 = app.world.spawn(Player::default()).id(); let player2 = app.world.spawn(Player::default()).id(); // Create stack of effects let stack = app.world.resource_mut::<Stack>(); // Add monarch-granting effects to stack stack.push(StackItem { effect_type: EffectType::GrantMonarch, source_player: player1, target_player: Some(player1), ..Default::default() }); stack.push(StackItem { effect_type: EffectType::GrantMonarch, source_player: player2, target_player: Some(player2), ..Default::default() }); // Resolve stack resolve_stack(&mut app); // Verify last effect resolved made player2 the monarch (LIFO order) let monarch = app.world.resource::<MonarchState>(); assert_eq!(monarch.current_monarch, Some(player2)); } }
Integration Tests
Testing monarch interactions with other systems:
#![allow(unused)] fn main() { #[test] fn test_monarch_integration_with_replacement_effects() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin) .add_plugin(ReplacementEffectsPlugin); // Setup test let player = app.world.spawn(Player::default()).id(); // Create a replacement effect that modifies card draw let replacement = app.world.spawn(( ReplacementEffect { effect_type: EffectType::DrawCard, replacement: ReplacementType::DrawExtraCard, controller: player, }, )).id(); // Make player the monarch app.world.send_event(AssignMonarchEvent { player }); app.update(); // Simulate end step let initial_hand_size = get_player_hand_size(&app, player); app.world.resource_mut::<TurnState>().current_player = player; app.world.resource_mut::<TurnState>().current_phase = Phase::End; app.world.send_event(PhaseEndEvent { phase: Phase::End }); app.update(); // Verify player drew 2 cards (1 from monarch + 1 from replacement) let new_hand_size = get_player_hand_size(&app, player); assert_eq!(new_hand_size, initial_hand_size + 2); } }
UI Testing
Testing the monarch UI representation:
#![allow(unused)] fn main() { #[test] fn test_monarch_ui_representation() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin) .add_plugin(UiPlugin); // Setup player let player = app.world.spawn(Player::default()).id(); // Make player the monarch app.world.send_event(AssignMonarchEvent { player }); app.update(); // Query for monarch UI elements let monarch_indicators = app.world.query_filtered::<Entity, With<MonarchIndicator>>() .iter(&app.world) .collect::<Vec<_>>(); // Verify monarch indicator exists assert!(!monarch_indicators.is_empty()); // Verify monarch indicator is attached to player let indicator = monarch_indicators[0]; let parent = app.world.get::<Parent>(indicator).unwrap(); // Find player entity in UI hierarchy let player_ui = find_player_ui_entity(&app, player); assert_eq!(parent.get(), player_ui); } }
Conclusion
The Monarch testing suite ensures that this important political mechanic functions correctly in all scenarios. These tests verify that the Monarch designation is properly assigned, transferred, and provides its card advantage benefit consistently.
Voting System Testing
Overview
This document outlines the comprehensive testing approach for the voting system in Rummage's Commander format implementation. The voting mechanic appears on various cards like Council's Judgment, Expropriate, and Capital Punishment, and represents a key political interaction point in multiplayer games.
Vote Initialization Tests
Tests for the initialization of voting events:
#![allow(unused)] fn main() { #[test] fn test_vote_initialization() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin); // Setup players let players = setup_four_player_game(&mut app); // Create vote options let option_a = "Exile target permanent"; let option_b = "Each player sacrifices a creature"; // Initialize vote let vote_id = app.world.send_event(InitializeVoteEvent { initiator: players[0], options: vec![option_a.to_string(), option_b.to_string()], timing_restriction: VoteTiming::OnResolve, }); app.update(); // Verify vote was created let vote_state = app.world.resource::<VoteRegistry>().get_vote(vote_id).unwrap(); assert_eq!(vote_state.options.len(), 2); assert_eq!(vote_state.initiator, players[0]); assert_eq!(vote_state.status, VoteStatus::Active); assert_eq!(vote_state.eligible_voters.len(), 4); } #[test] fn test_vote_with_timing_restrictions() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin) .add_plugin(TurnStructurePlugin); // Setup players let players = setup_four_player_game(&mut app); // Set current player and phase app.world.resource_mut::<TurnState>().current_player = players[0]; app.world.resource_mut::<TurnState>().current_phase = Phase::Main1; // Initialize vote with sorcery timing let vote_id = app.world.send_event(InitializeVoteEvent { initiator: players[0], options: vec!["Option A".to_string(), "Option B".to_string()], timing_restriction: VoteTiming::SorcerySpeed, }); app.update(); // Verify vote is allowed during main phase of initiator's turn let vote_state = app.world.resource::<VoteRegistry>().get_vote(vote_id).unwrap(); assert_eq!(vote_state.status, VoteStatus::Active); // Change to different player's turn app.world.resource_mut::<TurnState>().current_player = players[1]; // Try to initialize another vote with sorcery timing let vote_id2 = app.world.send_event(InitializeVoteEvent { initiator: players[1], options: vec!["Option X".to_string(), "Option Y".to_string()], timing_restriction: VoteTiming::SorcerySpeed, }); app.update(); // Verify second vote is active (as it's now player1's turn) let vote_state2 = app.world.resource::<VoteRegistry>().get_vote(vote_id2).unwrap(); assert_eq!(vote_state2.status, VoteStatus::Active); // Change to non-main phase app.world.resource_mut::<TurnState>().current_phase = Phase::Combat; // Try to initialize another vote with sorcery timing let vote_id3 = app.world.send_event(InitializeVoteEvent { initiator: players[1], options: vec!["Option C".to_string(), "Option D".to_string()], timing_restriction: VoteTiming::SorcerySpeed, }); app.update(); // Verify third vote is rejected due to timing let vote_state3 = app.world.resource::<VoteRegistry>().get_vote(vote_id3).unwrap(); assert_eq!(vote_state3.status, VoteStatus::Rejected); assert_eq!(vote_state3.rejection_reason, Some(VoteRejectionReason::InvalidTiming)); } }
Vote Casting Tests
Tests for the vote casting mechanics:
#![allow(unused)] fn main() { #[test] fn test_basic_vote_casting() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin); // Setup players let players = setup_four_player_game(&mut app); // Initialize vote let vote_id = app.world.send_event(InitializeVoteEvent { initiator: players[0], options: vec!["Option A".to_string(), "Option B".to_string()], timing_restriction: VoteTiming::OnResolve, }); app.update(); // Players cast votes app.world.send_event(CastVoteEvent { voter: players[0], vote_id, option_index: 0, // Option A }); app.world.send_event(CastVoteEvent { voter: players[1], vote_id, option_index: 0, // Option A }); app.world.send_event(CastVoteEvent { voter: players[2], vote_id, option_index: 1, // Option B }); app.world.send_event(CastVoteEvent { voter: players[3], vote_id, option_index: 1, // Option B }); app.update(); // Verify votes were recorded let vote_state = app.world.resource::<VoteRegistry>().get_vote(vote_id).unwrap(); assert_eq!(vote_state.votes.len(), 4); assert_eq!(vote_state.vote_counts[0], 2); // Option A has 2 votes assert_eq!(vote_state.vote_counts[1], 2); // Option B has 2 votes } #[test] fn test_vote_weight_modifiers() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin); // Setup players let players = setup_four_player_game(&mut app); // Add a vote weight modifier to player0 app.world.entity_mut(players[0]).insert(VoteWeightModifier { multiplier: 2.0, expiration: VoteWeightExpiration::Permanent, }); // Initialize vote let vote_id = app.world.send_event(InitializeVoteEvent { initiator: players[0], options: vec!["Option A".to_string(), "Option B".to_string()], timing_restriction: VoteTiming::OnResolve, }); app.update(); // Players cast votes app.world.send_event(CastVoteEvent { voter: players[0], vote_id, option_index: 0, // Option A - should count as 2 votes }); app.world.send_event(CastVoteEvent { voter: players[1], vote_id, option_index: 1, // Option B }); app.world.send_event(CastVoteEvent { voter: players[2], vote_id, option_index: 1, // Option B }); app.update(); // Verify vote weights were applied correctly let vote_state = app.world.resource::<VoteRegistry>().get_vote(vote_id).unwrap(); assert_eq!(vote_state.vote_counts[0], 2); // Option A has 2 votes (from weighted player) assert_eq!(vote_state.vote_counts[1], 2); // Option B has 2 votes (1 each from two players) } #[test] fn test_vote_time_limits() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin) .add_plugin(TimePlugin); // Setup players let players = setup_four_player_game(&mut app); // Initialize vote with time limit let vote_id = app.world.send_event(InitializeVoteEvent { initiator: players[0], options: vec!["Option A".to_string(), "Option B".to_string()], timing_restriction: VoteTiming::OnResolve, time_limit: Some(std::time::Duration::from_secs(30)), }); app.update(); // Players cast some votes app.world.send_event(CastVoteEvent { voter: players[0], vote_id, option_index: 0, }); app.world.send_event(CastVoteEvent { voter: players[1], vote_id, option_index: 1, }); app.update(); // Fast forward time past the limit advance_time(&mut app, std::time::Duration::from_secs(31)); app.update(); // Verify vote was automatically closed let vote_state = app.world.resource::<VoteRegistry>().get_vote(vote_id).unwrap(); assert_eq!(vote_state.status, VoteStatus::Closed); // Try to cast another vote app.world.send_event(CastVoteEvent { voter: players[2], vote_id, option_index: 0, }); app.update(); // Verify late vote was not counted let vote_state = app.world.resource::<VoteRegistry>().get_vote(vote_id).unwrap(); assert_eq!(vote_state.votes.len(), 2); // Still only 2 votes } }
Vote Resolution Tests
Tests for resolving votes and applying their effects:
#![allow(unused)] fn main() { #[test] fn test_vote_resolution_with_clear_winner() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin); // Setup players let players = setup_four_player_game(&mut app); // Initialize vote let vote_id = app.world.send_event(InitializeVoteEvent { initiator: players[0], options: vec!["Option A".to_string(), "Option B".to_string()], timing_restriction: VoteTiming::OnResolve, }); app.update(); // Players cast votes with clear winner app.world.send_event(CastVoteEvent { voter: players[0], vote_id, option_index: 0, }); app.world.send_event(CastVoteEvent { voter: players[1], vote_id, option_index: 0, }); app.world.send_event(CastVoteEvent { voter: players[2], vote_id, option_index: 0, }); app.world.send_event(CastVoteEvent { voter: players[3], vote_id, option_index: 1, }); app.update(); // Resolve vote app.world.send_event(ResolveVoteEvent { vote_id }); app.update(); // Verify correct option won let vote_state = app.world.resource::<VoteRegistry>().get_vote(vote_id).unwrap(); assert_eq!(vote_state.status, VoteStatus::Resolved); assert_eq!(vote_state.winning_option, Some(0)); // Option A won } #[test] fn test_vote_tie_breaking() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin); // Setup players let players = setup_four_player_game(&mut app); // Initialize vote with tie-breaker let vote_id = app.world.send_event(InitializeVoteEvent { initiator: players[0], options: vec!["Option A".to_string(), "Option B".to_string()], timing_restriction: VoteTiming::OnResolve, tie_breaker: VoteTieBreaker::Initiator, }); app.update(); // Players cast votes resulting in tie app.world.send_event(CastVoteEvent { voter: players[0], vote_id, option_index: 0, // Initiator votes for Option A }); app.world.send_event(CastVoteEvent { voter: players[1], vote_id, option_index: 0, }); app.world.send_event(CastVoteEvent { voter: players[2], vote_id, option_index: 1, }); app.world.send_event(CastVoteEvent { voter: players[3], vote_id, option_index: 1, }); app.update(); // Resolve vote app.world.send_event(ResolveVoteEvent { vote_id }); app.update(); // Verify initiator's choice won the tie let vote_state = app.world.resource::<VoteRegistry>().get_vote(vote_id).unwrap(); assert_eq!(vote_state.status, VoteStatus::Resolved); assert_eq!(vote_state.winning_option, Some(0)); // Option A won due to initiator tie-break // Test different tie-breaker: Random let vote_id2 = app.world.send_event(InitializeVoteEvent { initiator: players[0], options: vec!["Option X".to_string(), "Option Y".to_string()], timing_restriction: VoteTiming::OnResolve, tie_breaker: VoteTieBreaker::Random, }); app.update(); // Players cast votes resulting in tie for player in &players { app.world.send_event(CastVoteEvent { voter: *player, vote_id: vote_id2, option_index: player.index() % 2, // Evenly split votes }); } app.update(); // Set up deterministic RNG for testing app.insert_resource(TestRng::with_seed(12345)); // Resolve vote app.world.send_event(ResolveVoteEvent { vote_id: vote_id2 }); app.update(); // Verify a random winner was selected let vote_state = app.world.resource::<VoteRegistry>().get_vote(vote_id2).unwrap(); assert_eq!(vote_state.status, VoteStatus::Resolved); assert!(vote_state.winning_option.is_some()); // Some option was chosen randomly } #[test] fn test_vote_effect_application() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin); // Setup players let players = setup_four_player_game(&mut app); // Create test permanent to potentially exile let target_permanent = app.world.spawn(( Permanent { controller: players[1], ..Default::default() }, )).id(); // Create vote with exile effect let vote_id = app.world.send_event(InitializeVoteEvent { initiator: players[0], options: vec!["Exile permanent".to_string(), "Draw cards".to_string()], timing_restriction: VoteTiming::OnResolve, effects: vec![ VoteEffect::ExilePermanent { target: target_permanent }, VoteEffect::DrawCards { player: players[0], count: 2 }, ], }); app.update(); // Players vote to exile the permanent for player in &players { app.world.send_event(CastVoteEvent { voter: *player, vote_id, option_index: 0, // Everyone votes to exile }); } app.update(); // Resolve vote app.world.send_event(ResolveVoteEvent { vote_id }); app.update(); // Verify permanent was exiled let zone = app.world.get::<Zone>(target_permanent).unwrap(); assert_eq!(*zone, Zone::Exile); } }
Edge Case Tests
Testing unusual voting scenarios:
#![allow(unused)] fn main() { #[test] fn test_vote_abstention() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin); // Setup players let players = setup_four_player_game(&mut app); // Initialize vote let vote_id = app.world.send_event(InitializeVoteEvent { initiator: players[0], options: vec!["Option A".to_string(), "Option B".to_string()], timing_restriction: VoteTiming::OnResolve, allow_abstention: true, }); app.update(); // Two players vote, two abstain app.world.send_event(CastVoteEvent { voter: players[0], vote_id, option_index: 0, }); app.world.send_event(CastVoteEvent { voter: players[1], vote_id, option_index: 1, }); // Players 2 and 3 abstain by not voting // Close vote manually app.world.send_event(ResolveVoteEvent { vote_id }); app.update(); // Verify vote was counted correctly with abstentions let vote_state = app.world.resource::<VoteRegistry>().get_vote(vote_id).unwrap(); assert_eq!(vote_state.votes.len(), 2); // Only 2 votes cast assert_eq!(vote_state.eligible_voters.len(), 4); // But 4 eligible voters assert_eq!(vote_state.abstention_count, 2); // 2 abstentions } #[test] fn test_vote_with_modifiers() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin); // Setup players let players = setup_four_player_game(&mut app); // Create card that affects votes let vote_modifier_card = app.world.spawn(( Card::default(), VoteModifier { effect_type: VoteModifierType::DoubleVotesForOption { option_index: 0 }, controller: players[0], }, )).id(); // Initialize vote let vote_id = app.world.send_event(InitializeVoteEvent { initiator: players[0], options: vec!["Option A".to_string(), "Option B".to_string()], timing_restriction: VoteTiming::OnResolve, }); app.update(); // Players cast votes app.world.send_event(CastVoteEvent { voter: players[0], vote_id, option_index: 0, // This vote should be doubled }); app.world.send_event(CastVoteEvent { voter: players[1], vote_id, option_index: 0, }); app.world.send_event(CastVoteEvent { voter: players[2], vote_id, option_index: 1, }); app.world.send_event(CastVoteEvent { voter: players[3], vote_id, option_index: 1, }); app.update(); // Resolve vote app.world.send_event(ResolveVoteEvent { vote_id }); app.update(); // Verify vote modifier was applied let vote_state = app.world.resource::<VoteRegistry>().get_vote(vote_id).unwrap(); assert_eq!(vote_state.modified_vote_counts[0], 3); // 2 votes, but player0's vote doubled assert_eq!(vote_state.modified_vote_counts[1], 2); // 2 normal votes assert_eq!(vote_state.winning_option, Some(0)); } }
UI and Network Tests
Testing the voting UI and network synchronization:
#![allow(unused)] fn main() { #[test] fn test_vote_ui_representation() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin) .add_plugin(UiPlugin); // Setup players let players = setup_four_player_game(&mut app); // Initialize vote let vote_id = app.world.send_event(InitializeVoteEvent { initiator: players[0], options: vec!["Option A".to_string(), "Option B".to_string()], timing_restriction: VoteTiming::OnResolve, }); app.update(); // Verify vote UI elements were created let vote_ui_elements = app.world.query_filtered::<Entity, With<VoteUiElement>>() .iter(&app.world) .collect::<Vec<_>>(); assert!(!vote_ui_elements.is_empty()); // Verify options are displayed let vote_options = app.world.query_filtered::<&Text, With<VoteOptionText>>() .iter(&app.world) .collect::<Vec<_>>(); assert_eq!(vote_options.len(), 2); assert!(vote_options[0].sections[0].value.contains("Option A")); assert!(vote_options[1].sections[0].value.contains("Option B")); } #[test] fn test_vote_network_synchronization() { let mut app = TestNetworkApp::new(); app.add_plugins(MinimalPlugins) .add_plugin(PoliticsPlugin) .add_plugin(NetworkPlugin); // Setup networked game with host and client let (host_id, client_id) = app.setup_network_game(2); // Host initializes vote app.send_host_message(InitializeVoteMessage { initiator: host_id, options: vec!["Option A".to_string(), "Option B".to_string()], vote_id: VoteId::new(), }); app.update(); // Verify vote was synchronized to client let client_votes = app.get_client_resource::<VoteRegistry>().active_votes(); assert_eq!(client_votes.len(), 1); // Client casts vote app.send_client_message(CastVoteMessage { voter: client_id, vote_id: client_votes[0].id, option_index: 1, }); app.update(); // Verify vote was recorded on host let host_vote = app.get_host_resource::<VoteRegistry>().get_vote(client_votes[0].id).unwrap(); assert_eq!(host_vote.votes.len(), 1); assert_eq!(host_vote.votes[0].voter, client_id); assert_eq!(host_vote.votes[0].option_index, 1); } }
Conclusion
The voting system is a complex but essential political mechanic in Commander format. These tests ensure that votes are properly initialized, cast, tallied, and resolved under all circumstances, ensuring a fair and predictable voting experience for players.
Special Rules Tests
This section contains tests related to the special rules and mechanics unique to the Commander format.
Tests
Commander-Specific Rules Tests
Tests for the implementation of Commander-specific rules, including:
- Color identity restrictions
- Commander damage tracking
- Command zone mechanics
- Commander tax
- Commander replacement effects
- Partner and Background mechanics
- Commander ninjutsu
- Commander death triggers
These tests ensure that the unique rules of Commander are properly implemented and function correctly within the game engine.
Commander-Specific Rules Tests
Overview
This document outlines test cases for Commander-specific rules, including starting life totals, commander tax, color identity restrictions, singleton deck construction, and other format-specific mechanics that don't fit into other test categories.
Test Case: Starting Life Total
Test: 40 Life for Commander Format
#![allow(unused)] fn main() { #[test] fn test_commander_starting_life() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, initialize_game); // Set game format app.insert_resource(GameFormat::Commander); // Create players let player1 = app.world.spawn(Player {}).id(); let player2 = app.world.spawn(Player {}).id(); let player3 = app.world.spawn(Player {}).id(); let player4 = app.world.spawn(Player {}).id(); // Initialize game app.world.send_event(InitializeGameEvent { players: vec![player1, player2, player3, player4], }); app.update(); // Verify all players have 40 life for player in [player1, player2, player3, player4].iter() { let health = app.world.get::<Health>(*player).unwrap(); assert_eq!(health.current, 40); assert_eq!(health.maximum, 40); } } }
Test Case: 100-Card Singleton Deck Validation
Test: Singleton Rule (Only One Copy of Each Card)
#![allow(unused)] fn main() { #[test] fn test_singleton_rule() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, validate_commander_deck); // Create player let player = app.world.spawn(Player {}).id(); // Create a commander let commander = app.world.spawn(( Card { name: "Commander".to_string() }, Commander { owner: player, cast_count: 0 }, )).id(); // Create cards for the deck let card1 = app.world.spawn(( Card { name: "Unique Card 1".to_string() }, CardIdentity { oracle_id: "id1".to_string() }, )).id(); let card2 = app.world.spawn(( Card { name: "Unique Card 2".to_string() }, CardIdentity { oracle_id: "id2".to_string() }, )).id(); // Duplicate card (same oracle ID) let duplicate_card = app.world.spawn(( Card { name: "Unique Card 1".to_string() }, CardIdentity { oracle_id: "id1".to_string() }, )).id(); // Basic land (allowed multiple copies) let basic_land = app.world.spawn(( Card { name: "Forest".to_string() }, CardIdentity { oracle_id: "forest_id".to_string() }, BasicLand, )).id(); let basic_land2 = app.world.spawn(( Card { name: "Forest".to_string() }, CardIdentity { oracle_id: "forest_id".to_string() }, BasicLand, )).id(); // Create deck with the commander app.world.spawn(( Deck { owner: player }, CommanderDeck { commander }, Cards { entities: vec![card1, card2, duplicate_card, basic_land, basic_land2] }, )); // Validate deck app.update(); // Verify validation errors for duplicate card let validation_errors = app.world.resource::<ValidationErrors>(); assert!(!validation_errors.is_empty()); // Should have error about duplicate card let duplicate_error = validation_errors.errors.iter().any(|error| { error.card == duplicate_card && error.error_type == ValidationErrorType::DuplicateCard }); assert!(duplicate_error); // Should NOT have error about basic lands let basic_land_error = validation_errors.errors.iter().any(|error| { error.card == basic_land || error.card == basic_land2 }); assert!(!basic_land_error); } }
Test: Exactly 100 Cards Including Commander
#![allow(unused)] fn main() { #[test] fn test_deck_size() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, validate_commander_deck); // Create player let player = app.world.spawn(Player {}).id(); // Create a commander let commander = app.world.spawn(( Card { name: "Commander".to_string() }, Commander { owner: player, cast_count: 0 }, )).id(); // Create 98 cards for deck (which is invalid, should be 99 + commander) let mut cards = Vec::new(); for i in 0..98 { let card = app.world.spawn(( Card { name: format!("Card {}", i) }, CardIdentity { oracle_id: format!("id{}", i) }, )).id(); cards.push(card); } // Create deck with the commander app.world.spawn(( Deck { owner: player }, CommanderDeck { commander }, Cards { entities: cards.clone() }, )); // Validate deck app.update(); // Verify validation error for deck size let validation_errors = app.world.resource::<ValidationErrors>(); assert!(!validation_errors.is_empty()); let size_error = validation_errors.errors.iter().any(|error| { error.error_type == ValidationErrorType::InvalidDeckSize }); assert!(size_error); // Add one more card to make it 99 + commander = 100 let last_card = app.world.spawn(( Card { name: "Card 99".to_string() }, CardIdentity { oracle_id: "id99".to_string() }, )).id(); cards.push(last_card); // Update deck app.world.query_mut::<&mut Cards>().for_each_mut(|mut deck_cards| { deck_cards.entities = cards.clone(); }); // Clear previous errors app.world.resource_mut::<ValidationErrors>().errors.clear(); // Re-validate deck app.update(); // Verify no validation errors for deck size let validation_errors = app.world.resource::<ValidationErrors>(); let size_error = validation_errors.errors.iter().any(|error| { error.error_type == ValidationErrorType::InvalidDeckSize }); assert!(!size_error); } }
Test Case: Commander Tax Implementation
Test: Tracking Commander Tax Across Zone Changes
#![allow(unused)] fn main() { #[test] fn test_commander_tax_tracking() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (update_commander_tax, handle_zone_transitions, cast_from_command_zone)); // Create player let player = app.world.spawn(( Player {}, Mana::default(), )).id(); // Create commander let commander = app.world.spawn(( Card { name: "Test Commander".to_string(), cost: ManaCost::from_string("{3}{R}").unwrap(), }, Commander { owner: player, cast_count: 0 }, Zone::CommandZone, )).id(); // Cast commander first time app.world.send_event(CastSpellEvent { caster: player, spell: commander, }); app.update(); // Verify commander moved to stack assert_eq!(app.world.get::<Zone>(commander).unwrap(), &Zone::Stack); // Verify cast count increased to 1 assert_eq!(app.world.get::<Commander>(commander).unwrap().cast_count, 1); // Resolve spell (move to battlefield) app.world.send_event(ZoneChangeEvent { entity: commander, from: Zone::Stack, to: Zone::Battlefield, cause: ZoneChangeCause::SpellResolution, }); app.update(); // Send to command zone app.world.send_event(ZoneChangeEvent { entity: commander, from: Zone::Battlefield, to: Zone::CommandZone, cause: ZoneChangeCause::CommanderReplacement, }); app.update(); // Cast commander second time app.world.send_event(CastSpellEvent { caster: player, spell: commander, }); app.update(); // Verify cast count increased to 2 assert_eq!(app.world.get::<Commander>(commander).unwrap().cast_count, 2); // Verify cost now includes commander tax (2 additional mana) let required_mana = app.world.resource::<LastCastAttempt>().required_mana.clone(); assert_eq!(required_mana.total_cmc(), 7); // 3R + 2 tax = 7 } }
Test Case: Partner Commanders
Test: Two Partner Commanders
#![allow(unused)] fn main() { #[test] fn test_partner_commanders() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, validate_commander_setup); // Create player let player = app.world.spawn(Player {}).id(); // Create two partner commanders let commander1 = app.world.spawn(( Card { name: "Partner Commander 1".to_string() }, Commander { owner: player, cast_count: 0 }, PartnerAbility, ColorIdentity { colors: vec![Color::Red, Color::White] }, )).id(); let commander2 = app.world.spawn(( Card { name: "Partner Commander 2".to_string() }, Commander { owner: player, cast_count: 0 }, PartnerAbility, ColorIdentity { colors: vec![Color::Blue, Color::Black] }, )).id(); // Set commanders for player app.world.spawn(( CommanderList { commanders: vec![commander1, commander2] }, Owner { player }, )); // Validate commander setup app.update(); // Verify no validation errors let validation_errors = app.world.resource::<ValidationErrors>(); assert!(validation_errors.is_empty()); // Verify combined color identity let color_identity = app.world.resource::<CombinedColorIdentity>(); let player_identity = color_identity.get_colors(player); assert!(player_identity.contains(&Color::Red)); assert!(player_identity.contains(&Color::White)); assert!(player_identity.contains(&Color::Blue)); assert!(player_identity.contains(&Color::Black)); } }
Test: Two Non-Partner Commanders (Invalid)
#![allow(unused)] fn main() { #[test] fn test_invalid_multiple_commanders() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, validate_commander_setup); // Create player let player = app.world.spawn(Player {}).id(); // Create two regular commanders without partner let commander1 = app.world.spawn(( Card { name: "Commander 1".to_string() }, Commander { owner: player, cast_count: 0 }, ColorIdentity { colors: vec![Color::Green] }, )).id(); let commander2 = app.world.spawn(( Card { name: "Commander 2".to_string() }, Commander { owner: player, cast_count: 0 }, ColorIdentity { colors: vec![Color::Black] }, )).id(); // Set commanders for player app.world.spawn(( CommanderList { commanders: vec![commander1, commander2] }, Owner { player }, )); // Validate commander setup app.update(); // Verify validation errors let validation_errors = app.world.resource::<ValidationErrors>(); assert!(!validation_errors.is_empty()); // Should have error about too many commanders without partner let multi_commander_error = validation_errors.errors.iter().any(|error| { error.error_type == ValidationErrorType::MultipleCommandersWithoutPartner }); assert!(multi_commander_error); } }
Test Case: Commander Color Identity
Test: Combined Color Identity with Partner Commanders
#![allow(unused)] fn main() { #[test] fn test_color_identity_calculation() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (calculate_color_identity, validate_commander_deck)); // Create player let player = app.world.spawn(Player {}).id(); // Create partner commanders with different color identities let commander1 = app.world.spawn(( Card { name: "Silas Renn".to_string() }, Commander { owner: player, cast_count: 0 }, PartnerAbility, ColorIdentity { colors: vec![Color::Blue, Color::Black] }, )).id(); let commander2 = app.world.spawn(( Card { name: "Akiri".to_string() }, Commander { owner: player, cast_count: 0 }, PartnerAbility, ColorIdentity { colors: vec![Color::Red, Color::White] }, )).id(); // Set commanders for player app.world.spawn(( CommanderList { commanders: vec![commander1, commander2] }, Owner { player }, )); // Calculate combined color identity app.update(); // Verify color identity includes all colors from both commanders let color_identity = app.world.resource::<CombinedColorIdentity>(); let player_identity = color_identity.get_colors(player); assert!(player_identity.contains(&Color::Blue)); assert!(player_identity.contains(&Color::Black)); assert!(player_identity.contains(&Color::Red)); assert!(player_identity.contains(&Color::White)); assert!(!player_identity.contains(&Color::Green)); // Should not have green // Create cards with various color identities let valid_card = app.world.spawn(( Card { name: "Esper Card".to_string() }, ColorIdentity { colors: vec![Color::White, Color::Blue, Color::Black] }, )).id(); let invalid_card = app.world.spawn(( Card { name: "Green Card".to_string() }, ColorIdentity { colors: vec![Color::Green] }, )).id(); // Create deck with these cards app.world.spawn(( Deck { owner: player }, CommanderDeck { commander: commander1 }, // Just refers to one commander Cards { entities: vec![valid_card, invalid_card] }, )); // Validate deck against color identity app.update(); // Verify validation error for card outside color identity let validation_errors = app.world.resource::<ValidationErrors>(); assert!(!validation_errors.is_empty()); let color_identity_error = validation_errors.errors.iter().any(|error| { error.card == invalid_card && error.error_type == ValidationErrorType::ColorIdentityViolation }); assert!(color_identity_error); // Should NOT have error for valid card let valid_card_error = validation_errors.errors.iter().any(|error| { error.card == valid_card }); assert!(!valid_card_error); } }
Test: Color Identity Includes Mana Symbols in Rules Text
#![allow(unused)] fn main() { #[test] fn test_color_identity_from_rules_text() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, calculate_comprehensive_color_identity); // Create player let player = app.world.spawn(Player {}).id(); // Create cards with various color identity components // Card with mana cost only let card1 = app.world.spawn(( Card { name: "Red Creature".to_string(), cost: ManaCost::from_string("{1}{R}").unwrap(), }, RulesText { text: "This is a red creature.".to_string() }, )).id(); // Card with color indicator let card2 = app.world.spawn(( Card { name: "Blue Card".to_string(), cost: ManaCost::from_string("{1}").unwrap(), }, ColorIndicator { color: Color::Blue }, )).id(); // Card with mana symbol in rules text let card3 = app.world.spawn(( Card { name: "Colorless Card".to_string(), cost: ManaCost::from_string("{1}").unwrap(), }, RulesText { text: "{G}: This card gets +1/+1 until end of turn.".to_string() }, )).id(); // Card with hybrid mana in cost let card4 = app.world.spawn(( Card { name: "Hybrid Card".to_string(), cost: ManaCost::from_string("{1}{W/B}").unwrap(), }, )).id(); // Calculate comprehensive color identity app.update(); // Verify correct color identities let c1 = app.world.get::<ColorIdentity>(card1).unwrap(); assert_eq!(c1.colors.len(), 1); assert!(c1.colors.contains(&Color::Red)); let c2 = app.world.get::<ColorIdentity>(card2).unwrap(); assert_eq!(c2.colors.len(), 1); assert!(c2.colors.contains(&Color::Blue)); let c3 = app.world.get::<ColorIdentity>(card3).unwrap(); assert_eq!(c3.colors.len(), 1); assert!(c3.colors.contains(&Color::Green)); let c4 = app.world.get::<ColorIdentity>(card4).unwrap(); assert_eq!(c4.colors.len(), 2); assert!(c4.colors.contains(&Color::White)); assert!(c4.colors.contains(&Color::Black)); } }
Test Case: Commander Damage
Test: Commander Damage Win Condition
#![allow(unused)] fn main() { #[test] fn test_commander_damage_win_condition() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (apply_combat_damage, check_commander_damage_win_condition)); // Create players let player1 = app.world.spawn(( Player { id: 1 }, Health { current: 40, maximum: 40 }, CommanderDamage::default(), )).id(); let player2 = app.world.spawn(( Player { id: 2 }, Health { current: 40, maximum: 40 }, )).id(); // Create commander for player 2 let commander = app.world.spawn(( Card { name: "Commander".to_string() }, Commander { owner: player2, cast_count: 0 }, Creature { power: 7, toughness: 7 }, Zone::Battlefield, )).id(); // Apply commander damage 3 times (7 * 3 = 21 damage) for _ in 0..3 { app.world.send_event(CommanderDamageEvent { commander, target: player1, amount: 7, }); app.update(); } // Verify player received 21 commander damage let commander_damage = app.world.get::<CommanderDamage>(player1).unwrap(); assert_eq!(commander_damage.get_damage(commander), 21); // Verify player was eliminated due to commander damage let player_status = app.world.get::<PlayerStatus>(player1).unwrap(); assert_eq!(player_status.state, PlayerState::Eliminated); // Verify elimination reason is commander damage assert_eq!(player_status.elimination_reason, Some(EliminationReason::CommanderDamage)); } }
Test: Tracking Commander Damage Separately
#![allow(unused)] fn main() { #[test] fn test_multiple_commander_damage_tracking() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, apply_combat_damage); // Create player and opponents let player = app.world.spawn(( Player {}, Health { current: 40, maximum: 40 }, CommanderDamage::default(), )).id(); let opponent1 = app.world.spawn(Player { id: 1 }).id(); let opponent2 = app.world.spawn(Player { id: 2 }).id(); let opponent3 = app.world.spawn(Player { id: 3 }).id(); // Create commanders for each opponent let commander1 = app.world.spawn(( Card { name: "Commander 1".to_string() }, Commander { owner: opponent1, cast_count: 0 }, Creature { power: 3, toughness: 3 }, )).id(); let commander2 = app.world.spawn(( Card { name: "Commander 2".to_string() }, Commander { owner: opponent2, cast_count: 0 }, Creature { power: 5, toughness: 5 }, )).id(); let commander3 = app.world.spawn(( Card { name: "Commander 3".to_string() }, Commander { owner: opponent3, cast_count: 0 }, Creature { power: 2, toughness: 2 }, )).id(); // Apply damage from each commander app.world.send_event(CommanderDamageEvent { commander: commander1, target: player, amount: 3, }); app.world.send_event(CommanderDamageEvent { commander: commander2, target: player, amount: 5, }); app.world.send_event(CommanderDamageEvent { commander: commander3, target: player, amount: 2, }); app.update(); // Verify damage was tracked separately for each commander let commander_damage = app.world.get::<CommanderDamage>(player).unwrap(); assert_eq!(commander_damage.get_damage(commander1), 3); assert_eq!(commander_damage.get_damage(commander2), 5); assert_eq!(commander_damage.get_damage(commander3), 2); // Verify total life loss is the sum of all commander damage let health = app.world.get::<Health>(player).unwrap(); assert_eq!(health.current, 30); // 40 - (3+5+2) // Apply more damage from commander2 to reach lethal from a single commander app.world.send_event(CommanderDamageEvent { commander: commander2, target: player, amount: 16, }); app.update(); // Verify commander2 damage is now 21 let commander_damage = app.world.get::<CommanderDamage>(player).unwrap(); assert_eq!(commander_damage.get_damage(commander2), 21); // Verify player was eliminated let player_status = app.world.get::<PlayerStatus>(player).unwrap(); assert_eq!(player_status.state, PlayerState::Eliminated); } }
Test Case: Command Zone Mechanics
Test: Commander Replacement Effects for Different Zones
#![allow(unused)] fn main() { #[test] fn test_commander_replacement_effects() { // Test setup let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, (handle_zone_transitions, commander_replacement_effects)); // Create player with commander zone choice preferences let player = app.world.spawn(( Player {}, CommanderZoneChoice::default(), // Defaults to sending commanders to command zone )).id(); // Create commander let commander = app.world.spawn(( Card { name: "Commander".to_string() }, Commander { owner: player, cast_count: 0 }, Zone::Battlefield, )).id(); // Test commander dying (would go to graveyard) app.world.send_event(ZoneChangeEvent { entity: commander, from: Zone::Battlefield, to: Zone::Graveyard, cause: ZoneChangeCause::Death, }); app.update(); // Verify commander went to command zone instead assert_eq!(app.world.get::<Zone>(commander).unwrap(), &Zone::CommandZone); // Move commander back to battlefield for next test app.world.get_mut::<Zone>(commander).unwrap().0 = Zone::Battlefield.0; // Test commander being exiled app.world.send_event(ZoneChangeEvent { entity: commander, from: Zone::Battlefield, to: Zone::Exile, cause: ZoneChangeCause::Exile, }); app.update(); // Verify commander went to command zone instead assert_eq!(app.world.get::<Zone>(commander).unwrap(), &Zone::CommandZone); // Test with player choosing to let commander go to graveyard app.world.get_mut::<CommanderZoneChoice>(player).unwrap().use_command_zone = false; // Move commander back to battlefield app.world.get_mut::<Zone>(commander).unwrap().0 = Zone::Battlefield.0; // Test commander dying with choice to go to graveyard app.world.send_event(ZoneChangeEvent { entity: commander, from: Zone::Battlefield, to: Zone::Graveyard, cause: ZoneChangeCause::Death, }); app.update(); // Verify commander went to graveyard as chosen assert_eq!(app.world.get::<Zone>(commander).unwrap(), &Zone::Graveyard); } }
These test cases provide comprehensive coverage for Commander-specific rules and mechanics that form the foundation of the format.
Commander Format: Core Rules Integration
This document explains how the Commander format extends and modifies the core Magic: The Gathering rules in Rummage.
Architecture Overview
Commander builds upon the format-agnostic core MTG rules through a layered architecture:
┌───────────────────────────────────────────┐
│ Commander Format │
│ ┌─────────────┐ ┌────────────────────┐ │
│ │ Commander │ │ Commander-Specific │ │
│ │ Components │ │ Systems │ │
│ └─────────────┘ └────────────────────┘ │
├───────────────────────────────────────────┤
│ Extension Points │
│ ┌─────────────┐ ┌────────────────────┐ │
│ │ Plugin │ │ Override │ │
│ │ Registration│ │ Mechanisms │ │
│ └─────────────┘ └────────────────────┘ │
├───────────────────────────────────────────┤
│ Core MTG Rules │
│ ┌─────────────┐ ┌────────────────────┐ │
│ │ Base │ │ Base │ │
│ │ Components │ │ Systems │ │
│ └─────────────┘ └────────────────────┘ │
└───────────────────────────────────────────┘
This approach provides:
- Clean Separation: Format-specific code doesn't contaminate core rules
- Reusability: Core rules can support multiple formats
- Testability: Format mechanics can be tested in isolation
- Extensibility: New formats can be added without modifying core code
Core Extension Points
Commander leverages these primary extension mechanisms:
1. Zone Management Extensions
The core rules provide standard zones (library, hand, battlefield, etc.) which Commander extends with:
#![allow(unused)] fn main() { // Core system handles standard zones fn core_zone_setup(mut commands: Commands) { // Create standard zones for each player // ... } // Commander plugin adds Command Zone support fn commander_zone_setup(mut commands: Commands, players: Query<Entity, With<Player>>) { for player in &players { // Create Command Zone for this player commands.spawn(( ZoneType::Command, ZoneContents::default(), BelongsToPlayer(player), CommandZone, Name::new("Command Zone"), )); } } }
2. Game Setup Extensions
Commander modifies initial game parameters:
#![allow(unused)] fn main() { // Commander plugin modifies core game setup fn commander_game_setup( mut game_config: ResMut<GameConfig>, mut commands: Commands, players: Query<Entity, With<Player>>, ) { // Set Commander-specific configuration game_config.starting_life = 40; game_config.mulligan_rule = MulliganRule::CommanderFreeFirst; // Add Commander-specific components to players for player in &players { commands.entity(player).insert(CommanderDamageTracker::default()); } } }
3. Rules System Extensions
Commander adds new rule checks and state-based actions:
#![allow(unused)] fn main() { // Core state-based action system fn core_state_based_actions(/* ... */) { // Check creature death, player life, etc. // ... } // Commander adds additional state-based actions fn commander_state_based_actions( players: Query<(Entity, &CommanderDamageTracker)>, mut game_events: EventWriter<GameEvent>, ) { // Check commander damage thresholds for (player, damage_tracker) in &players { for (&commander, &damage) in damage_tracker.damage_taken.iter() { if damage >= 21 { game_events.send(GameEvent::PlayerLost { player, reason: LossReason::CommanderDamage(commander), }); } } } } }
Commander-Specific Overrides
In certain cases, Commander needs to override default core behavior:
Zone Transfer Overrides
When a commander would change zones, Commander rules allow for special handling:
#![allow(unused)] fn main() { // Commander zone transfer override fn commander_zone_transfer_system( mut zone_events: EventReader<ZoneChangeEvent>, mut player_choices: EventWriter<PlayerChoiceEvent>, commanders: Query<Entity, With<Commander>>, ) { for event in zone_events.iter() { // If this is a commander... if commanders.contains(event.entity) { // And it's moving to graveyard/exile/hand/library... if matches!(event.to, ZoneType::Graveyard | ZoneType::Exile | ZoneType::Hand | ZoneType::Library ) { // Give the controller a choice player_choices.send(PlayerChoiceEvent { player: get_controller(event.entity), choice_type: ChoiceType::CommanderZoneChange { commander: event.entity, default_zone: event.to, command_zone: ZoneType::Command, }, timeout: Duration::from_secs(30), }); } } } } }
Commander Tax Implementation
The Commander format adds tax to casting commanders from the Command Zone:
#![allow(unused)] fn main() { // Commander casting cost modification fn apply_commander_tax( mut cast_events: EventReader<PrepareTocastEvent>, mut cast_costs: Query<&mut ManaCost>, commanders: Query<&Commander>, zones: Query<(&ZoneContents, &ZoneType)>, ) { for event in cast_events.iter() { // Check if this is a commander being cast if let Ok(commander) = commanders.get(event.card) { // Check if it's being cast from Command Zone if is_in_command_zone(event.card, &zones) { // Apply commander tax if let Ok(mut cost) = cast_costs.get_mut(event.card) { let tax_amount = commander.cast_count * 2; cost.add_generic(tax_amount); } } } } } }
Plugin Implementation
The Commander format is implemented as a Bevy plugin:
#![allow(unused)] fn main() { pub struct CommanderPlugin; impl Plugin for CommanderPlugin { fn build(&self, app: &mut App) { // Register Commander-specific components app.register_type::<Commander>() .register_type::<CommanderDamageTracker>() .register_type::<CommanderTax>(); // Add Commander-specific resources app.init_resource::<CommanderConfig>(); // Add Commander setup systems app.add_systems(Startup, ( commander_game_setup, commander_zone_setup, )); // Add Commander-specific gameplay systems app.add_systems(PreUpdate, ( commander_zone_transfer_system .after(core_zone_change_system), )); // Add Commander rule enforcement systems app.add_systems(Update, ( apply_commander_tax, track_commander_damage, commander_state_based_actions .after(core_state_based_actions), )); // Add Commander-specific events app.add_event::<CommanderCastEvent>() .add_event::<CommanderDamageEvent>(); } } }
Example: Commander Zone Integration
The Command Zone is central to the Commander format. Here's its complete implementation:
#![allow(unused)] fn main() { // Commander-specific components #[derive(Component, Reflect)] pub struct Commander { pub owner: Entity, pub cast_count: u32, } #[derive(Component, Reflect)] pub struct CommandZone; // Command Zone setup fn commander_zone_setup( mut commands: Commands, players: Query<Entity, With<Player>>, mut decks: Query<(&mut DeckList, &BelongsToPlayer)>, ) { // Create Command Zone for each player for player in &players { // Create Command Zone entity let command_zone = commands.spawn(( ZoneType::Command, ZoneContents::default(), BelongsToPlayer(player), CommandZone, Name::new("Command Zone"), )).id(); // Find player's deck and locate commander if let Some((mut deck_list, _)) = decks .iter_mut() .find(|(_, belongs_to)| belongs_to.0 == player) { // Extract commander(s) from deck if let Some(commander_card) = deck_list.extract_commander() { // Spawn commander entity let commander_entity = commands.spawn(( Card::from_definition(commander_card), Commander { owner: player, cast_count: 0, }, InZone(ZoneType::Command), )).id(); // Add commander to command zone commands.entity(command_zone) .update_component(|mut contents: Mut<ZoneContents>| { contents.entities.push(commander_entity); }); } } } } }
Testing Integration
Commander integration testing focuses on verifying that format-specific rules correctly interact with core systems:
#![allow(unused)] fn main() { #[test] fn test_commander_zone_transfer() { // Setup test environment with both core and commander plugins let mut app = App::new(); app.add_plugins(CoreRulesPlugin) .add_plugins(CommanderPlugin); // Setup test commander and zones let (player, commander) = setup_test_commander(&mut app); // Test that commander can move to command zone instead of graveyard app.world.send_event(ZoneChangeEvent { entity: commander, from: ZoneType::Battlefield, to: ZoneType::Graveyard, }); // Handle player choice to move to command zone resolve_commander_zone_choice(&mut app, player, commander, ZoneType::Command); // Verify commander is in command zone let zone = get_entity_zone(&app, commander); assert_eq!(zone, ZoneType::Command, "Commander should be in Command Zone"); } }
Conclusion
The Commander format implementation builds upon the core MTG rules through a clean plugin architecture that:
- Extends base functionality with Commander-specific features
- Overrides certain behaviors to match Commander rules
- Adds new components and systems unique to Commander
This approach maintains the integrity of the core rules while enabling the unique gameplay experience of the Commander format.
For detailed information on specific Commander mechanics, see:
Card Systems
This section documents the card systems of the Rummage MTG Commander game engine, covering the card database, deck database, effects implementation, rendering, and testing strategies.
Table of Contents
Overview
The Card Systems module is the heart of Rummage's MTG implementation, responsible for representing cards, their attributes, and behaviors. This module handles everything from card data storage to deck management, effect resolution and visual representation.
In Rummage's ECS architecture, cards are entities with various components that define their properties, current state, and behaviors. These components include card type, mana cost, power/toughness for creatures, and other attributes defined in the MTG Comprehensive Rules. Systems then process these components to implement game mechanics such as casting spells, resolving abilities, and applying state-based actions.
Key Components
The Card Systems consist of the following major components:
-
- Storage and retrieval of card data
- Card attributes and properties
- Oracle text processing
- Card metadata and identification
-
- Deck creation and management
- Format-specific validation
- Deck persistence and sharing
- Runtime deck operations
-
- Effect resolution system
- Targeting mechanism
- Complex card interactions
- Ability parsing and implementation
-
- Visual representation of cards
- Card layout and templating
- Art asset management
- Dynamic card state visualization
-
- Effect verification methodology
- Interaction testing
- Edge case coverage
- Rules compliance verification
Implementation Status
Component | Status | Description |
---|---|---|
Core Card Model | ✅ | Basic card data structure and properties |
Card Database | ✅ | Storage and retrieval of card information |
Deck Database | ✅ | Deck creation, storage, and manipulation |
Format Validation | 🔄 | Deck validation for various formats |
Basic Effects | ✅ | Simple card effects (damage, draw, etc.) |
Complex Effects | 🔄 | Advanced card effects and interactions |
Targeting System | 🔄 | System for selecting targets for effects |
Card Rendering | ✅ | Visual representation of cards |
Effect Testing | 🔄 | Comprehensive testing of card effects |
Card Symbols | ✅ | Rendering of mana symbols and other icons |
Keywords | 🔄 | Implementation of MTG keywords |
Ability Resolution | 🔄 | Resolving triggered and activated abilities |
Legend:
- ✅ Implemented and tested
- 🔄 In progress
- ⚠️ Planned but not yet implemented
Integration with Game UI
The Card Systems module works closely with the Game UI module to create a seamless player experience. This integration occurs through several key interfaces:
Visual Representation
The Card Rendering system provides the necessary data for the Game UI to visualize cards on screen:
- The rendering pipeline transforms card data into visual assets
- Dynamic updates reflect card state changes (tapped, counters, attachments)
- Special visual effects for activated abilities and spells being cast
Deck Management
The Deck Database integrates with the UI to provide deck building and management interfaces:
- Deck builder UI for creating and editing decks
- Deck validation feedback
- Importing and exporting deck lists
- Deck statistics and analysis
User Interaction
The Card Systems module exposes interaction points that the Game UI uses to enable player actions:
- Dragging cards between zones
- Targeting for spells and abilities
- Selecting options for modal abilities
- Viewing card details and related information
State Feedback
As card states change due to game actions, this information is communicated to the UI:
- Legal play highlighting (e.g., showing which cards can be cast)
- Targeting validity feedback
- Stack visualization as spells and abilities resolve
For a complete understanding of how cards are visualized and interacted with in the game, continue to the Game UI System documentation, which builds upon the foundational card systems described here.
For more detailed information about card systems, please refer to the specific subsections.
Card Database
The card database is the central repository of all card information in Rummage. It stores the canonical data for every card in the supported Magic: The Gathering sets.
Database Structure
The card database is organized to efficiently store and retrieve card data:
- Card entries: Core card data including name, cost, types, etc.
- Card versions: Variations of cards across different sets
- Rules text: Formatted and parsed rules text for each card
- Card attributes: Power, toughness, loyalty, etc.
- Related cards: Connections between cards (tokens, meld pairs, etc.)
Data Sources
Card data is sourced from:
- Official Wizards of the Coast databases
- Community-maintained data repositories
- Hand-curated rules interpretations
Implementation
The database is implemented using a combination of:
- Static data files: Core card information stored in structured formats
- Runtime components: In-memory representation with efficient lookups
- Serialization: Conversion between storage and runtime formats
Loading and Caching
The database implements efficient loading and caching:
- Lazy loading: Only load cards as needed
- Set-based loading: Load cards by set for organized play
- Caching: Keep frequently used cards in memory
Card Lookup
Cards can be searched by various criteria:
- Name: Exact or fuzzy name matching
- Mana cost: Specific mana cost or converted mana cost
- Type: Card types, subtypes, and supertypes
- Text: Full-text search of rules text
- Format legality: Cards legal in specific formats
Extensibility
The database is designed for extensibility:
- New sets: Simple process to add new card sets
- Custom cards: Support for user-created cards
- Format updates: Easy to update format legality
Related Documentation
- Data Structure: Detailed structure of card data
- Card Attributes: Attributes tracked for each card
- Card Effects: How card effects are implemented
- Card Rendering: How cards are displayed
Data Structure
This document details the structure of card data in the Rummage database, explaining how card information is organized and stored.
Card Data Model
The core card data is modeled using a structured format:
Primary Card Entities
- CardDefinition: The fundamental card entry containing all card data
- CardEdition: Specific information for a card in a particular set
- CardFace: Data for one face of a card (for multi-faced cards)
Core Card Data
Each card contains these core attributes:
#![allow(unused)] fn main() { // Simplified example of core card data structure pub struct CardDefinition { pub oracle_id: Uuid, // Unique identifier pub name: String, // Card name pub mana_cost: Option<String>,// Mana cost string pub type_line: String, // Type line text pub oracle_text: String, // Oracle rules text pub colors: Vec<Color>, // Card colors pub color_identity: Vec<Color>,// Color identity pub keywords: Vec<String>, // Keyword abilities pub power: Option<String>, // Power (for creatures) pub toughness: Option<String>,// Toughness (for creatures) pub loyalty: Option<String>, // Loyalty (for planeswalkers) pub card_faces: Vec<CardFace>,// Multiple faces if applicable pub legalities: Legalities, // Format legalities pub reserved: bool, // On the reserved list? } }
Storage Format
Card data is stored in multiple formats:
- JSON files: Static card data stored on disk
- Binary format: Optimized runtime representation
- Database: For web-based implementations
JSON Structure
The JSON structure follows a standardized format for interoperability:
{
"oracle_id": "a3fb7228-e76b-4e96-a40e-20b5fed75685",
"name": "Lightning Bolt",
"mana_cost": "{R}",
"type_line": "Instant",
"oracle_text": "Lightning Bolt deals 3 damage to any target.",
"colors": ["R"],
"color_identity": ["R"],
"keywords": [],
"legalities": {
"standard": "not_legal",
"modern": "legal",
"commander": "legal"
}
}
Parsed Structures
Rules text and other complex fields are parsed into structured data:
Mana Cost Representation
Mana costs are parsed into a structured format:
#![allow(unused)] fn main() { pub struct ManaCost { pub generic: u32, // Generic mana amount pub white: u32, // White mana symbols pub blue: u32, // Blue mana symbols pub black: u32, // Black mana symbols pub red: u32, // Red mana symbols pub green: u32, // Green mana symbols pub colorless: u32, // Colorless mana symbols pub phyrexian: Vec<Color>, // Phyrexian mana symbols pub hybrid: Vec<(Color, Color)>, // Hybrid mana pairs pub x: bool, // Contains X in cost? } }
Rules Text Parsing
Rules text is parsed into an abstract syntax tree:
#![allow(unused)] fn main() { pub enum RulesTextNode { Text(String), Keyword(KeywordAbility), TriggeredAbility { trigger: Trigger, effect: Effect, }, ActivatedAbility { cost: Vec<Cost>, effect: Effect, }, StaticAbility(StaticEffect), } }
Card Relationships
The database tracks relationships between cards:
- Token creators: Cards that create tokens
- Meld pairs: Cards that meld together
- Companions: Cards with companion relationships
- Partners: Cards with partner abilities
- Flip sides: Two sides of double-faced cards
Indexing and Lookup
The data structure includes optimized indexes for:
- Name lookup: Fast retrieval by card name
- Type lookup: Finding cards by type
- Text search: Finding cards with specific rules text
- Color lookup: Finding cards by color identity
- Format lookup: Finding cards legal in specific formats
Data Versioning
The database supports versioning of card data:
- Oracle updates: Tracking rules text changes
- Erratas: Handling card corrections
- Set releases: Managing new card additions
- Format changes: Updating format legalities
Related Documentation
- Card Attributes: Detailed information about card attributes
- Effect Implementation: How card effects are implemented from data
- Card Database: Overview of the card database system
Card Attributes
This document details the attributes tracked for each card in the Rummage database. These attributes define a card's characteristics, behavior, and appearance.
Core Attributes
Identification
- Oracle ID: Unique identifier for the card's Oracle text
- Name: The card's name
- Set Code: The three or four-letter code for the card's set
- Collector Number: The card's number within its set
- Rarity: Common, uncommon, rare, mythic rare, or special
Card Type Information
- Type Line: The full type line text
- Supertypes: Legendary, Basic, Snow, etc.
- Card Types: Creature, Instant, Sorcery, etc.
- Subtypes: Human, Wizard, Equipment, Aura, etc.
Mana and Color
- Mana Cost: The card's mana cost
- Mana Value: The converted mana cost (total mana)
- Colors: The card's colors
- Color Identity: Colors in mana cost and rules text
- Color Indicator: For cards with no mana cost but have a color
Rules Text
- Oracle Text: The official rules text
- Flavor Text: The card's flavor text
- Keywords: Keyword abilities (Flying, Trample, etc.)
- Ability Words: Words that have no rules meaning (Landfall, etc.)
Combat Stats
- Power: Creature's power (attack strength)
- Toughness: Creature's toughness (health)
- Loyalty: Starting loyalty for planeswalkers
- Defense: Defense value for battles
Legality
- Format Legality: Legal status in various formats
- Reserved List: Whether the card is on the Reserved List
- Banned/Restricted: Status in various formats
Special Card Attributes
Multi-faced Cards
- Card Faces: Data for each face of the card
- Layout: Card layout type (normal, split, flip, etc.)
- Face Relationship: How faces relate to each other
Tokens
- Token Information: Data for tokens created by the card
- Token Colors: Colors of created tokens
- Token Types: Types of created tokens
Counters
- Counter Types: Types of counters the card uses
- Counter Placement: Where counters can be placed
- Counter Effects: Effects of counters on the card
Implementation
Card attributes are implemented as components in the ECS:
#![allow(unused)] fn main() { // Example of card type components #[derive(Component)] pub struct CardName(pub String); #[derive(Component)] pub struct ManaCost { pub cost_string: String, pub parsed: ParsedManaCost, } #[derive(Component)] pub struct CardTypes { pub supertypes: Vec<Supertype>, pub card_types: Vec<CardType>, pub subtypes: Vec<Subtype>, } #[derive(Component)] pub struct OracleText(pub String); #[derive(Component)] pub struct PowerToughness { pub power: String, pub toughness: String, } }
Attribute Modification
Card attributes can be modified by:
- Static Effects: Continuous modifications
- One-time Effects: Temporary changes
- Counters: Modifications from counters
- State-Based Actions: Automatic modifications
Attribute Access
Systems access card attributes through queries:
#![allow(unused)] fn main() { // Example of a system that queries card attributes fn check_creature_types( creatures: Query<(Entity, &CardTypes, &CardName)>, ) { for (entity, card_types, name) in creatures.iter() { if card_types.card_types.contains(&CardType::Creature) { // Process creature card let creature_subtypes = &card_types.subtypes; // ... } } } }
Attribute Serialization
Attributes are serialized for:
- Persistence: Saving game state
- Networking: Transmitting card data
- UI Display: Showing card information
Related Documentation
- Data Structure: Overall structure of card data
- Card Database: How card data is stored and retrieved
- Card Rendering: How attributes affect card appearance
Deck Database
The deck database in Rummage is a system for managing player decks, including creation, storage, validation, and manipulation during gameplay. This section documents the deck database architecture and implementation.
Overview
The deck database provides the following functionality:
- Deck Creation: Building and configuring new decks
- Storage: Persistent storage of deck data
- Validation: Checking decks against format rules
- Runtime Manipulation: In-game deck operations like drawing and shuffling
- Registry: Managing a collection of predefined and custom decks
Architecture
The deck database is implemented using a combination of:
- Disk Storage: JSON-based storage for deck persistence
- In-Memory Representation: Runtime deck entities and components
- Deck Registry: A global registry for decks available to all players
- Player-Specific Decks: Per-player deck instances
Core Components
The deck database consists of these key components:
- Deck Structure: The core Deck type and its related components
- Deck Builder: A builder pattern for constructing decks
- Deck Registry: A resource for registering and retrieving decks
- Deck Validation: Format-specific validation systems
- Deck Persistence: Saving and loading deck configurations
Integration with Game Systems
The deck database integrates with other game systems:
- Card Database: Drawing cards from the core card database
- Player Systems: Assigning decks to players
- Game Rules: Format-specific deck constraints
- UI Systems: Deck building and viewing interfaces
Format Support
The deck database supports multiple deck formats:
- Commander/EDH: 100-card singleton decks with a commander
- Standard: 60-card minimum with 4-copy maximum per card
- Modern/Legacy/Vintage: Format-specific banned and restricted lists
- Limited: 40-card minimum built from a limited card pool
- Custom: User-defined formats with custom rules
Related Documentation
- Deck Structure: Core data structures for decks
- Deck Builder: Building and validating decks
- Deck Registry: Managing multiple decks
- Format Validation: Format-specific deck constraints
- Card Integration: Integration with the card database
Deck Structure
This document details the core data structures used to represent decks in Rummage.
Core Types
The deck system is built around several key types:
Deck
The Deck
struct is the fundamental representation of a deck in Rummage:
#![allow(unused)] fn main() { /// Represents a deck of Magic cards #[derive(Debug, Clone)] pub struct Deck { /// Name of the deck pub name: String, /// Type of the deck (Commander, Standard, etc.) pub deck_type: DeckType, /// Cards in the deck pub cards: Vec<Card>, /// Commander card ID if this is a Commander deck pub commander: Option<Entity>, /// Partner commander card ID if applicable pub partner: Option<Entity>, /// Owner of the deck pub owner: Option<Entity>, } }
This structure contains all essential information about a deck, including its contents, format type, and ownership details.
PlayerDeck
The PlayerDeck
component attaches a deck to a player entity in the ECS:
#![allow(unused)] fn main() { /// Component to track a player's deck #[derive(Component, Debug, Clone)] pub struct PlayerDeck { /// The actual deck data pub deck: Deck, } }
This wrapper allows decks to be proper ECS components that can be independently queried and modified.
DeckType
The DeckType
enum defines the supported formats:
#![allow(unused)] fn main() { /// Represents different types of Magic decks #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum DeckType { /// Standard format deck (60 card minimum) Standard, /// Commander/EDH format deck (100 card singleton with Commander) Commander, /// Modern format deck Modern, /// Legacy format deck Legacy, /// Vintage format deck Vintage, /// Pauper format deck Pauper, /// Pioneer format deck Pioneer, /// Limited format deck (40 card minimum) Limited, /// Brawl format deck Brawl, /// Custom format with special rules Custom(String), } }
Each format has specific rules for deck construction and validation.
Deck Operations
The Deck
struct provides methods for common operations:
Creation and Setup
#![allow(unused)] fn main() { // Create a new deck pub fn new(name: String, deck_type: DeckType, cards: Vec<Card>) -> Self // Set the owner of this deck pub fn set_owner(&mut self, owner: Entity) // Set the commander for this deck pub fn set_commander(&mut self, commander: Entity) // Set the partner commander for this deck pub fn set_partner(&mut self, partner: Entity) }
Deck Manipulation
#![allow(unused)] fn main() { // Shuffle the deck pub fn shuffle(&mut self) // Draw a card from the top of the deck pub fn draw(&mut self) -> Option<Card> // Draw multiple cards from the top of the deck pub fn draw_multiple(&mut self, count: usize) -> Vec<Card> // Add a card to the top of the deck pub fn add_top(&mut self, card: Card) // Add a card to the bottom of the deck pub fn add_bottom(&mut self, card: Card) }
Deck Analysis
#![allow(unused)] fn main() { // Get the number of cards in the deck pub fn card_count(&self) -> usize // Search for cards by name pub fn search(&self, name: &str) -> Vec<&Card> // Validate the deck against format rules pub fn validate(&self) -> Result<(), DeckValidationError> }
Player Deck Operations
The PlayerDeck
component provides its own convenience methods:
#![allow(unused)] fn main() { // Create a new player deck component pub fn new(deck: Deck) -> Self // Draw a card from the top of the deck pub fn draw(&mut self) -> Option<Card> // Draw multiple cards from the top of the deck pub fn draw_multiple(&mut self, count: usize) -> Vec<Card> }
Validation
Deck validation is format-specific and can return various error types:
#![allow(unused)] fn main() { /// Errors that can occur during deck validation #[derive(Debug)] pub enum DeckValidationError { /// Deck doesn't have enough cards TooFewCards { required: usize, actual: usize }, /// Deck has illegal cards (e.g., banned cards) IllegalCards(Vec<String>), /// Deck has too many copies of a card TooManyCopies { card_name: String, max_allowed: usize, actual: usize, }, /// Deck has cards outside the Commander's color identity ColorIdentityViolation(Vec<String>), /// Commander is missing MissingCommander, /// Other validation errors OtherError(String), } }
Related Documentation
- Deck Builder: Creating and modifying decks
- Deck Registry: Managing multiple decks
- Format Validation: Format-specific deck constraints
Deck Builder
The deck builder system provides a flexible API for creating and configuring Magic decks in Rummage. It uses the builder pattern to provide a fluent interface for deck construction.
Builder Pattern
The DeckBuilder
struct follows the builder pattern, allowing incremental construction of a deck with clear, chainable methods:
#![allow(unused)] fn main() { /// Builder for creating decks #[derive(Default)] pub struct DeckBuilder { name: Option<String>, deck_type: Option<DeckType>, cards: Vec<Card>, commander: Option<Entity>, partner: Option<Entity>, owner: Option<Entity>, } }
This pattern makes deck creation more readable and easier to maintain.
Basic Usage
Creating a standard 60-card deck:
#![allow(unused)] fn main() { let deck = DeckBuilder::new() .with_name("My Standard Deck") .with_type(DeckType::Standard) .with_cards(my_cards) .with_owner(player_entity) .build()?; }
Creating a Commander deck:
#![allow(unused)] fn main() { let deck = DeckBuilder::new() .with_name("My Commander Deck") .with_type(DeckType::Commander) .with_cards(main_deck_cards) .with_commander(commander_entity) .with_owner(player_entity) .build()?; }
Available Methods
The DeckBuilder
provides these core methods:
Initialization
#![allow(unused)] fn main() { // Create a new empty deck builder pub fn new() -> Self }
Configuration
#![allow(unused)] fn main() { // Set the name of the deck pub fn with_name(mut self, name: &str) -> Self // Set the type of the deck pub fn with_type(mut self, deck_type: DeckType) -> Self // Set the commander (for Commander format) pub fn with_commander(mut self, commander: Entity) -> Self // Set the partner commander (for Commander format) pub fn with_partner(mut self, partner: Entity) -> Self // Set the owner of the deck pub fn with_owner(mut self, owner: Entity) -> Self }
Card Management
#![allow(unused)] fn main() { // Add multiple cards at once pub fn with_cards(mut self, cards: Vec<Card>) -> Self // Add a single card pub fn add_card(mut self, card: Card) -> Self // Add multiple copies of a card pub fn add_copies(mut self, card: Card, count: usize) -> Self }
Building
#![allow(unused)] fn main() { // Build the final deck pub fn build(self) -> Result<Deck, String> // Build a shuffled deck pub fn build_shuffled(self) -> Result<Deck, String> }
Implementation Details
The builder handles default values for optional fields:
#![allow(unused)] fn main() { pub fn build(self) -> Result<Deck, String> { let name = self.name.unwrap_or_else(|| "Untitled Deck".to_string()); let deck_type = self.deck_type.unwrap_or(DeckType::Standard); let mut deck = Deck::new(name, deck_type, self.cards); if let Some(commander) = self.commander { deck.set_commander(commander); } if let Some(partner) = self.partner { deck.set_partner(partner); } if let Some(owner) = self.owner { deck.set_owner(owner); } Ok(deck) } }
This ensures that decks always have reasonable defaults even when not all fields are specified.
Format-Specific Usage
Commander Decks
When building Commander decks, additional fields are required:
#![allow(unused)] fn main() { let deck = DeckBuilder::new() .with_name("Atraxa Superfriends") .with_type(DeckType::Commander) .with_cards(deck_cards) .with_commander(atraxa_entity) .build()?; }
Partner Commanders
For decks with partner commanders:
#![allow(unused)] fn main() { let deck = DeckBuilder::new() .with_name("Partners Deck") .with_type(DeckType::Commander) .with_cards(deck_cards) .with_commander(thrasios_entity) .with_partner(tymna_entity) .build()?; }
Validation
The builder doesn't perform validation during construction. Validation is handled separately when needed:
#![allow(unused)] fn main() { let deck = deck_builder.build()?; if let Err(validation_error) = deck.validate() { // Handle validation error } }
This separation allows for creating incomplete or invalid decks when necessary (e.g., during deck construction in the UI).
Related Documentation
- Deck Structure: Core data structures for decks
- Format Validation: Format-specific deck constraints
- Deck Registry: Managing multiple decks
Deck Registry
The Deck Registry is a global resource that manages collections of predefined and user-created decks. It provides a central repository for deck storage, retrieval, and management.
Registry Resource
The DeckRegistry
is implemented as a Bevy resource:
#![allow(unused)] fn main() { #[derive(Resource, Default)] pub struct DeckRegistry { decks: std::collections::HashMap<String, Deck>, } }
This resource is initialized during application startup:
#![allow(unused)] fn main() { // In the DeckPlugin implementation fn build(&self, app: &mut App) { app.init_resource::<DeckRegistry>() .add_systems(Startup, register_default_decks) .add_systems(Startup, shuffle_all_player_decks); } }
Core Registry Operations
The DeckRegistry
provides several key methods:
Registration
#![allow(unused)] fn main() { // Register a deck with the registry pub fn register_deck(&mut self, name: &str, deck: Deck) { self.decks.insert(name.to_string(), deck); } }
Retrieval
#![allow(unused)] fn main() { // Get a specific deck by name pub fn get_deck(&self, name: &str) -> Option<&Deck> { self.decks.get(name) } // Get all registered decks pub fn get_all_decks(&self) -> Vec<(&String, &Deck)> { self.decks.iter().collect() } }
Default Decks
The registry includes a startup system that registers default decks:
#![allow(unused)] fn main() { // Register default decks for testing/examples fn register_default_decks(mut registry: ResMut<DeckRegistry>) { // Register predefined decks // These could be loaded from files, created programmatically, etc. // Example: Register a basic test deck let test_deck = create_test_deck(); registry.register_deck("Test Deck", test_deck); // Example: Register Commander precons let precon_decks = create_precon_decks(); for (name, deck) in precon_decks { registry.register_deck(&name, deck); } } }
Integration with Player Systems
The registry is designed to work with player-specific deck instances:
#![allow(unused)] fn main() { // System to assign decks to players from the registry fn assign_decks_to_players( registry: Res<DeckRegistry>, mut commands: Commands, players: Query<(Entity, &PlayerPreferences)>, ) { for (player_entity, preferences) in players.iter() { if let Some(preferred_deck) = preferences.preferred_deck.as_ref() { if let Some(deck) = registry.get_deck(preferred_deck) { // Clone the deck from the registry let player_deck = PlayerDeck::new(deck.clone()); // Assign the deck to the player commands.entity(player_entity).insert(player_deck); } } } } }
Custom Deck Registration
Players can register their own custom decks:
#![allow(unused)] fn main() { // Register a player's custom deck fn register_custom_deck( mut registry: ResMut<DeckRegistry>, deck_builder: DeckBuilder, player_name: &str, ) -> Result<(), String> { let deck_name = format!("{}'s Custom Deck", player_name); let deck = deck_builder.build()?; registry.register_deck(&deck_name, deck); Ok(()) } }
Deck Shuffling
The registry works with a system that ensures all player decks are properly shuffled:
#![allow(unused)] fn main() { // System to ensure all player decks are properly shuffled independently fn shuffle_all_player_decks(mut player_decks: Query<&mut PlayerDeck>) { for mut player_deck in player_decks.iter_mut() { player_deck.deck.shuffle(); } } }
Persistence
In a complete implementation, the registry also handles saving and loading decks to/from disk:
#![allow(unused)] fn main() { // Save all registered decks to disk pub fn save_all_decks(&self, path: &Path) -> Result<(), io::Error> { // Implementation for serializing and saving decks } // Load decks from disk pub fn load_decks(&mut self, path: &Path) -> Result<(), io::Error> { // Implementation for loading and deserializing decks } }
Related Documentation
- Deck Structure: Core data structures for decks
- Deck Builder: Creating and modifying decks
- Format Validation: Format-specific deck constraints
Format Validation
The format validation system ensures that decks comply with the rules of their respective formats. Each Magic: The Gathering format has specific deck construction requirements that must be validated.
Validation Process
Deck validation is performed through the validate
method on the Deck
struct:
#![allow(unused)] fn main() { impl Deck { pub fn validate(&self) -> Result<(), DeckValidationError> { match self.deck_type { DeckType::Commander => self.validate_commander(), DeckType::Standard => self.validate_standard(), DeckType::Modern => self.validate_modern(), DeckType::Legacy => self.validate_legacy(), DeckType::Vintage => self.validate_vintage(), DeckType::Pauper => self.validate_pauper(), DeckType::Pioneer => self.validate_pioneer(), DeckType::Limited => self.validate_limited(), DeckType::Brawl => self.validate_brawl(), DeckType::Custom(ref _custom) => Ok(()), // Custom formats don't have fixed validation } } } }
Validation Errors
Validation failures return a DeckValidationError
that describes the specific issue:
#![allow(unused)] fn main() { pub enum DeckValidationError { /// Deck doesn't have enough cards TooFewCards { required: usize, actual: usize }, /// Deck has illegal cards (e.g., banned cards) IllegalCards(Vec<String>), /// Deck has too many copies of a card TooManyCopies { card_name: String, max_allowed: usize, actual: usize, }, /// Deck has cards outside the Commander's color identity ColorIdentityViolation(Vec<String>), /// Commander is missing MissingCommander, /// Other validation errors OtherError(String), } }
Format-Specific Validation
Each format has its own validation rules implemented as a private method:
Commander Validation
#![allow(unused)] fn main() { fn validate_commander(&self) -> Result<(), DeckValidationError> { // Check deck size (100 cards including commander) if self.cards.len() < 99 { return Err(DeckValidationError::TooFewCards { required: 99, actual: self.cards.len(), }); } // Check if commander exists if self.commander.is_none() { return Err(DeckValidationError::MissingCommander); } // Check singleton rule (except basic lands) let mut card_counts = std::collections::HashMap::new(); for card in &self.cards { if !card.is_basic_land() { *card_counts.entry(&card.name).or_insert(0) += 1; } } for (card_name, count) in card_counts { if count > 1 { return Err(DeckValidationError::TooManyCopies { card_name: card_name.to_string(), max_allowed: 1, actual: count, }); } } // Check color identity if let Some(commander_entity) = self.commander { // Logic to check that all cards match commander's color identity // ... } Ok(()) } }
Standard Validation
#![allow(unused)] fn main() { fn validate_standard(&self) -> Result<(), DeckValidationError> { // Check minimum deck size (60 cards) if self.cards.len() < 60 { return Err(DeckValidationError::TooFewCards { required: 60, actual: self.cards.len(), }); } // Check card copy limit (max 4 of any card except basic lands) let mut card_counts = std::collections::HashMap::new(); for card in &self.cards { if !card.is_basic_land() { *card_counts.entry(&card.name).or_insert(0) += 1; } } for (card_name, count) in card_counts { if count > 4 { return Err(DeckValidationError::TooManyCopies { card_name: card_name.to_string(), max_allowed: 4, actual: count, }); } } // Check for banned cards let banned_cards: Vec<_> = self.cards .iter() .filter(|card| is_banned_in_standard(card)) .map(|card| card.name.clone()) .collect(); if !banned_cards.is_empty() { return Err(DeckValidationError::IllegalCards(banned_cards)); } // Check set legality let illegal_sets: Vec<_> = self.cards .iter() .filter(|card| !is_legal_in_standard_sets(card)) .map(|card| card.name.clone()) .collect(); if !illegal_sets.is_empty() { return Err(DeckValidationError::IllegalCards(illegal_sets)); } Ok(()) } }
Format Rules Implementation
The validation system relies on several helper functions:
#![allow(unused)] fn main() { // Check if a card is a basic land fn is_basic_land(card: &Card) -> bool { // Implementation to check if a card is a basic land } // Check if a card is banned in a specific format fn is_banned_in_format(card: &Card, format: &DeckType) -> bool { // Implementation to check format-specific ban lists } // Check if a card's set is legal in the standard rotation fn is_legal_in_standard_sets(card: &Card) -> bool { // Implementation to check standard set legality } // Get a card's color identity fn get_color_identity(card: &Card) -> Vec<Color> { // Implementation to determine a card's color identity } }
UI Integration
The validation system integrates with the deck builder UI to provide immediate feedback:
#![allow(unused)] fn main() { // System to validate decks in the deck builder UI fn validate_deck_in_builder( deck: &Deck, mut validation_state: &mut ValidationState, ) { match deck.validate() { Ok(()) => { validation_state.is_valid = true; validation_state.errors.clear(); } Err(error) => { validation_state.is_valid = false; validation_state.errors.push(error); } } } }
Validation Timing
Validation is performed at several key points:
- During deck building: Validate as cards are added/removed
- Before saving: Ensure decks are valid before saving to the registry
- Before game start: Verify all player decks are valid for the format
- After format changes: Re-validate when card legality changes
Related Documentation
- Deck Structure: Core data structures for decks
- Deck Builder: Creating and modifying decks
- Card Database: Integration with the card database
Persistent Storage with bevy_persistent
This document details the implementation of robust save/load functionality for decks using bevy_persistent
, a crate that provides efficient and reliable persistence for Bevy resources.
Introduction to bevy_persistent
bevy_persistent
is a crate that enables easy persistence of Bevy resources to disk with automatic serialization and deserialization. It provides:
- Automatic saving: Resources are automatically saved when modified
- Error handling: Robust error handling and recovery
- Hot reloading: Changes to saved files can be detected and loaded at runtime
- Format flexibility: Support for various serialization formats (JSON, RON, TOML, etc.)
- Path configuration: Flexible configuration of save paths
Integrating bevy_persistent with DeckRegistry
The DeckRegistry
resource can be enhanced with bevy_persistent
to provide automatic, robust persistence:
#![allow(unused)] fn main() { use bevy::prelude::*; use bevy_persistent::prelude::*; use serde::{Deserialize, Serialize}; /// Persistent registry for storing decks #[derive(Resource, Serialize, Deserialize, Default)] pub struct PersistentDeckRegistry { /// All registered decks pub decks: std::collections::HashMap<String, Deck>, /// Last saved timestamp #[serde(skip)] pub last_saved: Option<std::time::SystemTime>, } // Extension for the DeckPlugin impl DeckPlugin { fn build(&self, app: &mut App) { // Initialize the persistent deck registry let persistent_registry = Persistent::<PersistentDeckRegistry>::builder() .name("decks") .format(StorageFormat::Ron) .path("user://deck_registry.ron") .default(PersistentDeckRegistry::default()) .build(); app.insert_resource(persistent_registry) .add_systems(Update, autosave_registry) .add_systems(Startup, load_decks_on_startup); } } }
Autosave System
The autosave system ensures decks are saved whenever they are modified:
#![allow(unused)] fn main() { /// System to automatically save the deck registry when modified fn autosave_registry( mut registry: ResMut<Persistent<PersistentDeckRegistry>>, time: Res<Time>, ) { // Check if registry was modified since last save if registry.is_changed() { // Only save every few seconds to avoid excessive disk I/O let now = std::time::SystemTime::now(); let should_save = match registry.last_saved { Some(last_saved) => { now.duration_since(last_saved) .unwrap_or_default() .as_secs() >= 5 } None => true, }; if should_save { info!("Auto-saving deck registry..."); if let Err(err) = registry.save() { error!("Failed to save deck registry: {}", err); } else { registry.last_saved = Some(now); info!("Deck registry saved successfully"); } } } } }
Loading Decks on Startup
Decks are automatically loaded when the application starts:
#![allow(unused)] fn main() { /// System to load decks on startup fn load_decks_on_startup( mut registry: ResMut<Persistent<PersistentDeckRegistry>>, mut commands: Commands, ) { info!("Loading deck registry from persistent storage..."); // Try to load the registry from disk match registry.load() { Ok(_) => { info!("Successfully loaded {} decks from registry", registry.decks.len()); // Additional setup for loaded decks if needed for (name, deck) in registry.decks.iter() { debug!("Loaded deck: {}", name); } } Err(err) => { error!("Failed to load deck registry: {}", err); info!("Using default empty registry instead"); } } } }
API for Deck Management
The persistent registry provides a clean API for deck management:
#![allow(unused)] fn main() { /// Add a deck to the registry and save it pub fn add_deck( mut registry: ResMut<Persistent<PersistentDeckRegistry>>, name: &str, deck: Deck, ) -> Result<(), String> { info!("Adding deck '{}' to registry", name); registry.decks.insert(name.to_string(), deck); match registry.save() { Ok(_) => { info!("Deck '{}' added and saved successfully", name); Ok(()) } Err(err) => { error!("Failed to save deck registry after adding '{}': {}", name, err); Err(format!("Failed to save: {}", err)) } } } /// Remove a deck from the registry pub fn remove_deck( mut registry: ResMut<Persistent<PersistentDeckRegistry>>, name: &str, ) -> Result<(), String> { if registry.decks.remove(name).is_none() { return Err(format!("Deck '{}' not found in registry", name)); } match registry.save() { Ok(_) => { info!("Deck '{}' removed and registry saved", name); Ok(()) } Err(err) => { error!("Failed to save registry after removing '{}': {}", name, err); Err(format!("Failed to save: {}", err)) } } } }
Error Recovery
The system includes mechanisms for error recovery in case of corruption:
#![allow(unused)] fn main() { /// System to handle corrupted deck files fn handle_corrupted_registry( mut registry: ResMut<Persistent<PersistentDeckRegistry>>, ) { // If loading failed due to deserialization errors if let Err(PersistentError::Deserialize(_)) = registry.try_load() { warn!("Deck registry file appears to be corrupted"); // Create a backup of the corrupted file if let Some(path) = registry.path() { let backup_path = format!("{}.backup", path.display()); if let Err(e) = std::fs::copy(path, backup_path.clone()) { error!("Failed to create backup of corrupted file: {}", e); } else { info!("Created backup of corrupted file at {}", backup_path); } } // Reset to default and save *registry = Persistent::builder() .name("decks") .format(StorageFormat::Ron) .path("user://deck_registry.ron") .default(PersistentDeckRegistry::default()) .build(); if let Err(e) = registry.save() { error!("Failed to save new default registry: {}", e); } else { info!("Reset deck registry to default state"); } } } }
Integration with Player Systems
The persistent deck registry can be integrated with player systems:
#![allow(unused)] fn main() { /// System to assign persistent decks to players fn assign_persistent_decks( registry: Res<Persistent<PersistentDeckRegistry>>, mut commands: Commands, players: Query<(Entity, &PlayerPreferences)>, ) { for (player_entity, preferences) in players.iter() { if let Some(preferred_deck) = preferences.preferred_deck.as_ref() { if let Some(deck) = registry.decks.get(preferred_deck) { // Clone the deck from the registry let player_deck = PlayerDeck::new(deck.clone()); // Assign the deck to the player commands.entity(player_entity).insert(player_deck); info!("Assigned deck '{}' to player", preferred_deck); } } } } }
Benefits Over Manual Persistence
Using bevy_persistent
offers several advantages over manual file I/O:
- Automatic Change Detection: Resources are only saved when actually modified
- Error Handling: Built-in error recovery mechanisms
- Hot Reloading: Changes to deck files can be detected at runtime
- Format Flexibility: Easy switching between serialization formats
- Path Management: Cross-platform handling of save paths
Related Documentation
- Deck Structure: Core data structures for decks
- Deck Registry: Managing multiple decks
- State Persistence: Using bevy_persistent for state rollbacks
Card Effects
Card effects are the mechanisms through which cards interact with the game state. This section documents how card effects are implemented, resolved, and tested in Rummage.
Overview
Magic: The Gathering cards have a wide variety of effects that modify the game state in different ways:
- Adding or removing counters
- Dealing damage
- Moving cards between zones
- Modifying attributes of other cards
- Creating tokens
- Altering the rules of the game
The effect system in Rummage is designed to handle all these interactions in a consistent and extensible way.
Implementation Approach
Effects are implemented using a combination of:
- Components: Data that defines the effect's properties
- Systems: Logic that processes and applies the effects
- Events: Notifications that trigger and respond to effects
- Queries: Selections of target entities to affect
Core Components
- Effect Resolution: How effects are processed and applied
- Targeting: How targets are selected and validated
- Complex Interactions: Handling interactions between multiple effects
- Special Card Implementations: Cards with unique game-altering effects like subgames and game restarting
Integration
The effects system integrates with:
- State Management: Effects modify game state
- Event System: Effects are triggered by and generate events
- MTG Rules: Effects follow the comprehensive rules
- Subgames and Game Restarting: Special rules for cards like Shahrazad and Karn Liberated
Testing
For information on how to test card effects, see Effect Verification.
Effect Resolution
Effect resolution is the process by which a card's effects are applied to the game state. This document outlines how Rummage implements this core game mechanic.
Resolution Process
The resolution of an effect follows these steps:
- Validation: Check if the effect can legally resolve
- Target Confirmation: Verify targets are still legal
- Effect Application: Apply the effect to the game state
- Triggered Abilities: Check for abilities triggered by the effect
- State-Based Actions: Check for state-based actions after resolution
Implementation
Effect resolution is implemented using Bevy's entity-component-system architecture:
#![allow(unused)] fn main() { // Example of a system that resolves damage effects fn resolve_damage_effects( mut commands: Commands, mut event_reader: EventReader<ResolveEffectEvent>, mut damage_effects: Query<(Entity, &DamageEffect, &EffectTargets)>, mut targets: Query<&mut Health>, mut damaged_event_writer: EventWriter<DamagedEvent> ) { for resolve_event in event_reader.read() { if let Ok((effect_entity, damage_effect, effect_targets)) = damage_effects.get(resolve_event.effect_entity) { // Apply damage to each target for &target in &effect_targets.entities { if let Ok(mut health) = targets.get_mut(target) { health.current -= damage_effect.amount; // Send a damage event for other systems to react to damaged_event_writer.send(DamagedEvent { entity: target, amount: damage_effect.amount, source: resolve_event.source_entity, }); } } // Clean up the resolved effect commands.entity(effect_entity).despawn(); } } } }
Effect Types
Different types of effects have specialized resolution procedures:
One-Shot Effects
These effects happen once and are immediately complete:
- Damage dealing
- Card drawing
- Life gain/loss
Continuous Effects
These effects modify the game state for a duration:
- Static abilities
- Enchantment effects
- "Until end of turn" effects
Replacement Effects
These effects replace one event with another:
- Damage prevention
- Alternative costs
- "Instead" effects
Effect Timing
Effects respect the timing rules of Magic:
- Instant speed: Can be played anytime priority is held
- Sorcery speed: Only during main phases of the controller's turn
- Triggered: When certain conditions are met
- Static: Continuous effects that always apply
Error Handling
Effect resolution includes error handling for cases where:
- Targets become illegal
- Effect requirements can't be met
- Rules prevent the effect from resolving
Related Documentation
- Targeting: How targets are selected and validated
- Complex Interactions: Handling multiple interacting effects
- Stack: How effects wait for resolution
- State-Based Actions: Automatic game actions after effects resolve
Targeting
Targeting is a fundamental mechanic in Magic: The Gathering that determines which game objects (cards, players, etc.) will be affected by a spell or ability. This document outlines how targeting is implemented in Rummage.
Core Concepts
Target Types
The targeting system supports various target types:
- Cards: Cards in any zone
- Players: Players in the game
- Tokens: Token creatures on the battlefield
- Spells: Spells on the stack
- Emblems: Persistent effects in the command zone
- Abilities: Abilities on the stack
Target Restrictions
Targets can be restricted based on:
- Characteristics: Card type, subtypes, color, power/toughness, etc.
- Game State: Tapped/untapped, controller, zone, etc.
- Custom Conditions: Any programmable condition
Implementation
Target Selection
Target selection is implemented using queries and predicates:
#![allow(unused)] fn main() { // Example of a targeting predicate for "target creature you control" pub fn creature_you_control_predicate( entity: Entity, world: &World, source_player: Entity, ) -> bool { let card_type = world.get::<CardType>(entity); let controller = world.get::<Controller>(entity); let zone = world.get::<Zone>(entity); matches!(card_type, Ok(CardType::Creature)) && matches!(controller, Ok(Controller(player)) if *player == source_player) && matches!(zone, Ok(Zone::Battlefield)) } }
Target Validation
Targets are validated at multiple points:
- During casting/activation: Initial target selection
- On resolution: Confirming targets are still legal
- During targeting events: When other effects change targeting
Illegal Targets
The system handles illegal targets by:
- Preventing spells with illegal targets from being cast
- Countering spells with illegal targets on resolution
- Removing illegal targets from multi-target effects
UI Integration
Targeting integrates with the UI system:
- Visual indicators: Highlighting valid targets
- Targeting arrows: Showing connections between source and targets
- Invalid target feedback: Indicating why a target is invalid
Special Cases
Protection
The system handles protection effects:
- Can't be targeted by spells/abilities with specific characteristics
- Can't be blocked by creatures with specific characteristics
- Can't be damaged by sources with specific characteristics
- Can't be enchanted/equipped by specific characteristics
Hexproof and Shroud
- Hexproof: Can't be targeted by spells/abilities opponents control
- Shroud: Can't be targeted by any spells/abilities
Changing Targets
The system supports effects that change targets:
- Redirect effects
- "Change the target" effects
- Copying with new targets
Related Documentation
- Effect Resolution: How effects are applied once targets are selected
- Complex Interactions: Handling interactions between targeting effects
- UI Targeting System: UI implementation of targeting
- MTG Targeting Rules: Official MTG rules for targeting
Complex Interactions
Magic: The Gathering is known for its intricate rule system and complex card interactions. This document outlines how Rummage handles these complex card interactions to ensure correct game behavior.
Layering System
MTG uses a layering system to determine how continuous effects are applied. Rummage implements this system following the comprehensive rules:
Layer Order
- Copy effects: Effects that make an object a copy of another object
- Control-changing effects: Effects that change control of an object
- Text-changing effects: Effects that change the text of an object
- Type-changing effects: Effects that change the types of an object
- Color-changing effects: Effects that change the colors of an object
- Ability-adding/removing effects: Effects that add or remove abilities
- Power/toughness-changing effects: Effects that modify power and toughness
Implementation
The layering system is implemented using a multi-pass approach:
#![allow(unused)] fn main() { // Example of layered effect application fn apply_continuous_effects( mut card_entities: Query<(Entity, &mut CardState)>, continuous_effects: Query<&ContinuousEffect>, timestamps: Query<&Timestamp>, ) { // Collect all effects let mut effects_by_layer = [Vec::new(); 7]; for (entity, effect) in continuous_effects.iter() { effects_by_layer[effect.layer as usize].push((entity, effect)); } // Sort effects within each layer by timestamp for layer_effects in &mut effects_by_layer { layer_effects.sort_by_key(|(entity, _)| timestamps.get(*entity).unwrap().0); } // Apply effects layer by layer for (layer_idx, layer_effects) in effects_by_layer.iter().enumerate() { for (_, effect) in layer_effects { apply_effect_in_layer(layer_idx, effect, &mut card_entities); } } } }
Dependency Chains
Some effects depend on the outcome of other effects. The system handles these dependencies by:
- Building a dependency graph of effects
- Topologically sorting the graph
- Applying effects in the correct order
State-Based Action Loops
Some complex interactions can create loops of state-based actions. The system:
- Detects potential loops
- Applies a maximum iteration limit
- Resolves terminal states correctly
Replacement Effects
Replacement effects modify how events occur. They're handled by:
- Tracking the original event
- Applying applicable replacement effects
- Determining the order when multiple effects apply
- Generating the modified event
Triggered Ability Resolution
When multiple abilities trigger simultaneously:
- APNAP order is used (Active Player, Non-Active Player)
- The controller of each ability chooses the order for their triggers
- Abilities are placed on the stack in that order
Example: Layers in Action
Here's an example of how the layers system handles a complex interaction:
Initial card: Grizzly Bears (2/2 green creature - Bear)
Effect 1: Darksteel Mutation (becomes 0/1 artifact creature with no abilities)
Effect 2: +1/+1 counter
Effect 3: Painter's Servant (becomes blue)
The system processes this as:
- Layer 4 (Type): Now an artifact creature
- Layer 5 (Color): Now a blue artifact creature
- Layer 6 (Abilities): Now has no abilities
- Layer 7 (P/T): Base P/T becomes 0/1, then +1/+1 counter makes it 1/2
Final result: 1/2 blue artifact creature with no abilities.
Related Documentation
- Effect Resolution: How individual effects are resolved
- Targeting: How targeting works with complex effects
- State-Based Actions: Automatic game rules that interact with effects
- Stack: How effects are ordered for resolution
Special Card Implementations
This document covers the implementation details of cards with unique and complex mechanics that require special handling in our engine.
Shahrazad
Shahrazad is a unique sorcery that creates a subgame:
Shahrazad {W}{W}
Sorcery
Players play a Magic subgame, using their libraries as their decks. Each player who doesn't win the subgame loses half their life, rounded up.
Implementation Details
Shahrazad requires deep integration with the game engine to manage subgames:
#![allow(unused)] fn main() { #[derive(Component)] pub struct Shahrazad; impl CardEffect for Shahrazad { fn resolve(&self, world: &mut World, effect_ctx: &EffectContext) -> EffectResult { // Get access to necessary systems let mut subgame_system = world.resource_mut::<SubgameSystem>(); // Start a subgame using the libraries as decks subgame_system.start_new_subgame(effect_ctx); // The subgame will run completely before continuing // Return pending to indicate this effect pauses resolution EffectResult::Pending } } // Called when the subgame completes pub fn apply_shahrazad_results( mut commands: Commands, mut player_query: Query<(Entity, &mut Life), With<Player>>, subgame_result: Res<SubgameResult>, ) { // Apply life loss to players who didn't win for (player_entity, mut life) in player_query.iter_mut() { if !subgame_result.winners.contains(&player_entity) { // Lose half life, rounded up life.value = life.value - (life.value + 1) / 2; } } } }
Testing Approach
Testing Shahrazad is complex due to its nesting capabilities:
- Unit Testing: We verify the subgame initialization and cleanup logic
- Integration Testing: We test interactions between the subgame and main game
- End-to-End Testing: We validate full gameplay flows with subgames
Example test:
#![allow(unused)] fn main() { #[test] fn test_shahrazad_life_loss() { let mut app = App::new(); // Setup test environment... // Initial life totals let player1_life = 20; let player2_life = 20; // Cast Shahrazad // ... test code to cast the spell ... // Simulate subgame where player 1 wins app.world.resource_mut::<SubgameResult>().winners = vec![player1_entity]; app.update(); // Triggers apply_shahrazad_results // Verify player 2 lost half their life let new_player2_life = app.world.get::<Life>(player2_entity).unwrap().value; assert_eq!(new_player2_life, 10); // 20 -> 10 } }
Karn Liberated
Karn Liberated is a planeswalker with an ultimate ability that restarts the game:
Karn Liberated {7}
Legendary Planeswalker — Karn
Starting loyalty: 6
+4: Target player exiles a card from their hand.
−3: Exile target permanent.
−14: Restart the game, leaving in exile all non-Aura permanent cards exiled with Karn Liberated. Then put those cards onto the battlefield under your control.
Implementation Details
Karn's implementation focuses on tracking exiled cards and restarting the game:
#![allow(unused)] fn main() { #[derive(Component)] pub struct KarnLiberated; #[derive(Component)] pub struct ExiledWithKarn; impl CardEffect for KarnUltimate { fn resolve(&self, world: &mut World, effect_ctx: &EffectContext) -> EffectResult { // Verify loyalty cost can be paid if !self.can_pay_loyalty_cost(world, effect_ctx, -14) { return EffectResult::Failed; } // Pay loyalty cost self.pay_loyalty_cost(world, effect_ctx, -14); // Trigger game restart let mut restart_events = world.resource_mut::<Events<GameRestartEvent>>(); restart_events.send(GameRestartEvent { source: effect_ctx.source_entity, restart_type: RestartType::KarnUltimate, }); EffectResult::Success } } // System that runs when a game restart event occurs pub fn handle_karn_restart( mut commands: Commands, mut restart_events: EventReader<GameRestartEvent>, exiled_cards: Query<(Entity, &CardData, &Owner), With<ExiledWithKarn>>, karn_controller: Query<&Controller, With<KarnLiberated>>, ) { for event in restart_events.read() { if event.restart_type != RestartType::KarnUltimate { continue; } // Get Karn's controller let karn_owner = karn_controller.get_single().unwrap(); // Track cards that will be put onto the battlefield let cards_to_return = exiled_cards .iter() .filter(|(_, card_data, _)| !card_data.is_aura()) .map(|(entity, _, _)| entity) .collect::<Vec<_>>(); // Initialize new game state... // Put exiled cards onto the battlefield under Karn owner's control for card_entity in cards_to_return { // Set controller to Karn's controller commands.entity(card_entity) .insert(Controller(karn_owner.0)) .remove::<ExiledWithKarn>(); // Move to battlefield // ... zone transition code ... } } } }
Testing Approach
Testing Karn Liberated's restart ability requires comprehensive state verification:
- Unit Testing: We verify card tracking and the restart triggering
- Integration Testing: We ensure exiled cards correctly transfer to the new game
- End-to-End Testing: We validate the complete game restart flow
Example test:
#![allow(unused)] fn main() { #[test] fn test_karn_ultimate_restart() { let mut app = App::new(); // Setup test environment... // Create test cards and exile them with Karn let test_card1 = spawn_test_card(&mut app.world, "Test Card 1"); let test_card2 = spawn_test_card(&mut app.world, "Test Card 2"); // Add ExiledWithKarn to the cards app.world.entity_mut(test_card1).insert(ExiledWithKarn); app.world.entity_mut(test_card2).insert(ExiledWithKarn); // Activate Karn's ultimate // ... test code to activate the ability ... // Verify game restarted and cards are on the battlefield let card1_zone = app.world.get::<Zone>(test_card1).unwrap(); assert_eq!(*card1_zone, Zone::Battlefield); let card1_controller = app.world.get::<Controller>(test_card1).unwrap(); assert_eq!(card1_controller.0, karn_controller_id); } }
Other Special Cards
Mindslaver
Mindslaver allows a player to control another player's turn. Implementation involves intercepting player choices and redirecting control.
Panglacial Wurm
Panglacial Wurm can be cast while searching a library. Implementation requires special handling of the timing and visibility rules during search effects.
Time Stop
Time Stop ends the turn immediately. Implementation involves carefully cleaning up the stack and skipping directly to the cleanup step.
Integration with Game Engine
Special cards leverage several core engine systems:
- Snapshot System for state management
- Event System for communicating complex state changes
- State Management for tracking game/turn state
For more on subgames and game restarting mechanics, see:
Card Rendering
Card rendering is responsible for the visual representation of Magic: The Gathering cards in Rummage. This system handles how cards are displayed in different zones, states, and views.
Rendering Philosophy
The card rendering system adheres to several key principles:
- Accuracy: Cards should resemble their physical counterparts when appropriate
- Readability: Card information should be easily readable at various scales
- Performance: Rendering should be efficient, even with many cards on screen
- Flexibility: The system should adapt to different screen sizes and device capabilities
Implementation
Card rendering is implemented using Bevy's rendering capabilities:
- Entity-Component-System: Cards are entities with rendering components
- Material system: Custom shaders for card effects
- Texture atlases: Efficient texture management for card art and symbols
- Text rendering: High-quality text rendering for card text
Card Layout
Cards are rendered with several key layout elements:
- Frame: The card border and background
- Art box: The card's artwork
- Title bar: The card's name and mana cost
- Type line: Card types, subtypes, and supertypes
- Text box: Rules text and flavor text
- Power/toughness box: For creatures
- Loyalty counter: For planeswalkers
- Set symbol: Indicating rarity and expansion
Art Assets
The rendering system uses various art assets:
- Card artwork: The main illustration for each card
- Frame templates: Card frames for different card types
- Mana symbols: Symbols representing mana costs
- Set symbols: Symbols for different expansions
- UI elements: Additional interface elements for card interactions
Special Cases
The system handles special rendering cases such as:
- Double-faced cards: Cards with two sides
- Split cards: Cards with two halves
- Adventure cards: Cards with an adventure component
- Leveler cards: Cards with level-up abilities
- Saga cards: Cards with chapter abilities
- Planeswalkers: Cards with loyalty abilities
Dynamic Rendering
Cards dynamically adjust their rendering based on:
- Zone: Different visual treatment in hand, battlefield, etc.
- State: Tapped, attacking, blocking, etc.
- Modifications: +1/+1 counters, auras attached, etc.
- Focus: Card under mouse hover or selection
Related Documentation
- Art Assets: Details on the art assets used for cards
- Card Layout: Specifics of card layout and design
- UI Integration: How card rendering integrates with the UI
- Card Database: Source of card data for rendering
Art Assets
This document details the art assets used in the Rummage card rendering system. These assets are essential for creating visually accurate and appealing card representations.
Asset Categories
The rendering system uses several categories of art assets:
Card Artwork
- Card illustrations: The main artwork for each card
- Alternative art: Alternate versions of card artwork
- Full-art treatments: Extended artwork for special cards
- Borderless treatments: Artwork that extends to the card edges
Card Frames
- Standard frames: Regular card frames for each card type
- Special frames: Unique frames for special card sets
- Showcase frames: Stylized frames for showcase cards
- Promo frames: Special frames for promotional cards
Symbols
- Mana symbols: Symbols representing different mana types
- Set symbols: Symbols for each Magic: The Gathering set
- Rarity indicators: Symbols indicating card rarity
- Ability symbols: Symbols for keyword abilities (e.g., tap symbol)
UI Elements
- Counter indicators: Visual elements for counters on cards
- Status indicators: Indicators for card states (tapped, etc.)
- Selection highlights: Visual feedback for selected cards
- Targeting arrows: Visual elements for targeting
Asset Management
Asset Loading
Assets are loaded using Bevy's asset system:
#![allow(unused)] fn main() { // Example of loading card assets fn load_card_assets( mut commands: Commands, asset_server: Res<AssetServer>, mut texture_atlases: ResMut<Assets<TextureAtlas>>, ) { // Load mana symbols texture atlas let mana_symbols_texture = asset_server.load("textures/mana_symbols.png"); let mana_atlas = TextureAtlas::from_grid( mana_symbols_texture, Vec2::new(32.0, 32.0), 6, 3, None, None ); let mana_atlas_handle = texture_atlases.add(mana_atlas); // Store the handle for later use commands.insert_resource(ManaSymbolsAtlas(mana_atlas_handle)); // Load card frame textures let standard_frame = asset_server.load("textures/frames/standard.png"); commands.insert_resource(StandardFrame(standard_frame)); // ... load other assets } }
Asset Optimization
The system optimizes asset usage through:
- Texture atlases: Combining multiple small textures
- Mipmap generation: For different viewing distances
- Asset caching: Reusing assets across multiple cards
- Lazy loading: Loading assets only when needed
Asset Creation Pipeline
Source Files
- Vector graphics: SVG files for symbols and UI elements
- High-resolution artwork: Source artwork files
- Template designs: Base designs for card frames
Processing
- Rasterization: Converting vector graphics to textures
- Compression: Optimizing file sizes
- Format conversion: Converting to engine-compatible formats
- Atlas generation: Creating texture atlases
Special Asset Types
Animated Assets
- Card animations: Special effects for certain cards
- Transition effects: Animations for state changes
- Foil effects: Simulating foil card appearance
- Parallax effects: Depth effects for special cards
Procedural Assets
- Dynamic frames: Frames generated based on card properties
- Custom counters: Counters generated for specific cards
- Adaptive backgrounds: Backgrounds that adapt to card colors
Asset Organization
Assets are organized in a structured directory hierarchy:
assets/
├── textures/
│ ├── card_art/
│ │ ├── set_code/
│ │ │ └── card_name.png
│ ├── frames/
│ │ ├── standard/
│ │ ├── special/
│ │ └── promo/
│ ├── symbols/
│ │ ├── mana/
│ │ ├── set/
│ │ └── ability/
│ └── ui/
│ ├── counters/
│ ├── indicators/
│ └── effects/
└── shaders/
├── card_effects/
└── special_treatments/
Related Documentation
- Card Layout: How assets are used in card layouts
- Card Rendering: Overview of the card rendering system
- UI Integration: How assets integrate with the UI
Card Layout
This document details the layout system used for rendering Magic: The Gathering cards in Rummage. The layout system ensures cards are displayed with the correct proportions, elements, and visual style.
Card Dimensions
Standard Magic cards have specific dimensions that are maintained in the rendering system:
- Physical card ratio: 2.5" × 3.5" (63mm × 88mm)
- Digital aspect ratio: 5:7 (0.714)
- Standard rendering size: Configurable based on UI scale
Layout Components
Cards are composed of several layout components:
Frame Components
- Border: The outermost edge of the card
- Frame: The colored frame indicating card type
- Text box: Area containing rules text
- Art box: Area containing the card's artwork
- Title bar: Area containing the card's name and mana cost
- Type line: Area containing the card's type information
- Expansion symbol: Symbol indicating the card's set and rarity
Text Components
- Name text: The card's name
- Type text: The card's type line
- Rules text: The card's rules text
- Flavor text: The card's flavor text
- Power/toughness: Creature's power and toughness
- Loyalty: Planeswalker's loyalty
Special Components
- Mana symbols: Symbols in the mana cost and rules text
- Watermark: Background symbol in the text box
- Foil pattern: Overlay for foil cards
- Collector information: Card number, artist, copyright
Layout System
The layout system uses a combination of:
- Relative positioning: Elements positioned relative to card dimensions
- Grid system: Invisible grid for consistent alignment
- Responsive scaling: Adjusting to different display sizes
- Dynamic text flow: Text that reflows based on content
Implementation
The layout is implemented using Bevy's UI system:
#![allow(unused)] fn main() { // Example of creating a card layout fn create_card_layout( commands: &mut Commands, card_entity: Entity, card_data: &CardData, assets: &CardAssets, ) { // Create the main card entity commands.entity(card_entity) .insert(Node::default()) .insert(Style { size: Size::new(Val::Px(300.0), Val::Px(420.0)), ..Default::default() }) .with_children(|parent| { // Add frame background parent.spawn(ImageBundle { style: Style { size: Size::new(Val::Percent(100.0), Val::Percent(100.0)), ..Default::default() }, image: assets.get_frame_for_card(card_data).into(), ..Default::default() }); // Add art box parent.spawn(ImageBundle { style: Style { size: Size::new(Val::Percent(90.0), Val::Percent(40.0)), margin: UiRect::new( Val::Percent(5.0), Val::Percent(5.0), Val::Percent(15.0), Val::Auto, ), ..Default::default() }, image: assets.get_art_for_card(card_data).into(), ..Default::default() }); // Add name text parent.spawn(TextBundle { style: Style { position_type: PositionType::Absolute, position: UiRect { top: Val::Px(20.0), left: Val::Px(20.0), ..Default::default() }, ..Default::default() }, text: Text::from_section( card_data.name.clone(), TextStyle { font: assets.card_name_font.clone(), font_size: 18.0, color: Color::BLACK, }, ), ..Default::default() }); // Add other card elements... }); } }
Special Card Layouts
The system supports various special card layouts:
Split Cards
- Layout: Two half-cards joined along an edge
- Orientation: Can be displayed horizontally or vertically
- Text scaling: Smaller text to fit in half-size areas
Double-Faced Cards
- Front face: Primary card face
- Back face: Secondary card face
- Transition: Smooth flip animation between faces
Adventure Cards
- Main card: The creature portion
- Adventure: The spell portion in a sub-frame
Saga Cards
- Chapter markers: Visual indicators for saga chapters
- Art layout: Extended art area
- Chapter text: Text for each chapter ability
Text Rendering
Text rendering is handled with special care:
- Font selection: Fonts that match the Magic card style
- Text scaling: Automatic scaling based on text length
- Symbol replacement: Replacing mana symbols in text
- Text wrapping: Proper wrapping of rules text
- Italics and emphasis: Styling for flavor text and reminders
Related Documentation
- Art Assets: The art assets used in card layouts
- Card Rendering: Overview of the card rendering system
- UI Integration: How card layouts integrate with the UI
Testing Cards
Card testing is a critical aspect of Rummage development, ensuring that cards function correctly according to Magic: The Gathering rules. This document outlines the approach and methodologies for testing cards in the system.
Testing Philosophy
The card testing system follows these principles:
- Comprehensive coverage: All cards should be tested for correct behavior
- Rule compliance: Cards must behave according to official MTG rules
- Edge case handling: Tests should cover uncommon interactions
- Regression prevention: Tests should catch regressions when code changes
- Documentation: Tests serve as documentation for expected behavior
Testing Categories
Cards are tested across several categories:
Functional Testing
- Basic functionality: Core card mechanics work as expected
- Rules text adherence: Card behaves according to its rules text
- Zone transitions: Card behaves correctly when moving between zones
- State changes: Card responds correctly to changes in game state
Interaction Testing
- Card-to-card interactions: How cards interact with other cards
- Rules interactions: How cards interact with game rules
- Timing interactions: How cards behave with timing-sensitive effects
- Priority interactions: How cards interact with the priority system
Edge Case Testing
- Corner cases: Unusual but valid game states
- Rules exceptions: Cards that modify or break standard rules
- Multiple effects: Cards with multiple simultaneous effects
- Layer interactions: How cards behave with the layer system
Testing Tools
The testing system provides several tools:
- Scenario builder: Create specific game states for testing
- Action sequencer: Execute a series of game actions
- State validator: Verify game state after actions
- Snapshot comparison: Compare game states before and after effects
- Rules oracle: Validate behavior against rules database
Example Test
Here's an example of a card test using the testing framework:
#![allow(unused)] fn main() { #[test] fn lightning_bolt_deals_3_damage() { // Arrange: Set up the test scenario let mut test = TestScenario::new(); let player1 = test.add_player(20); let player2 = test.add_player(20); let bolt = test.add_card_to_hand("Lightning Bolt", player1); // Act: Execute the actions test.play_card(bolt, Some(player2)); test.resolve_top_of_stack(); // Assert: Verify the outcome assert_eq!(test.get_player_life(player2), 17); } }
Testing Standards
All cards must pass these testing standards:
- Functionality tests: Basic functionality works
- Interaction tests: Works with related card types
- Rules compliance: Follows comprehensive rules
- Performance: Doesn't cause performance issues
- Visual correctness: Renders correctly
Continuous Integration
Card tests are run as part of the continuous integration pipeline:
- Tests run on every pull request
- Cards with failed tests cannot be merged
- Test coverage is tracked and reported
Related Documentation
- Effect Verification: How card effects are verified
- Interaction Testing: Testing complex card interactions
- Snapshot Testing: Using snapshots for card testing
- End-to-End Testing: Testing cards in full game scenarios
Effect Verification
This document outlines the methodologies and tools used to verify that card effects work correctly in Rummage. Effect verification is a critical part of ensuring that cards behave according to the Magic: The Gathering rules.
Verification Approach
Effect verification follows a systematic approach:
- Define expected behavior: Document how the effect should work
- Create test scenarios: Design scenarios that test the effect
- Execute tests: Run the tests and capture results
- Verify outcomes: Compare results to expected behavior
- Document edge cases: Record any special cases or interactions
Test Scenario Builder
The test scenario builder is a key tool for effect verification:
#![allow(unused)] fn main() { // Example of using the test scenario builder #[test] fn verify_lightning_bolt_effect() { // Create a test scenario let mut scenario = TestScenario::new(); // Set up players let player1 = scenario.add_player(20); let player2 = scenario.add_player(20); // Add cards to the game let bolt = scenario.add_card_to_hand("Lightning Bolt", player1); // Execute game actions scenario.play_card(bolt, Some(player2)); scenario.resolve_top_of_stack(); // Verify the effect assert_eq!(scenario.get_player_life(player2), 17); } }
Effect Categories
Different effect categories require specific verification approaches:
Damage Effects
- Direct damage: Verify damage is dealt to the correct target
- Conditional damage: Verify conditions are correctly evaluated
- Damage prevention: Verify prevention effects work correctly
- Damage redirection: Verify damage is redirected properly
Card Movement Effects
- Zone transitions: Verify cards move between zones correctly
- Search effects: Verify search functionality works properly
- Shuffle effects: Verify randomization is applied
- Reordering effects: Verify cards are reordered correctly
Modification Effects
- Stat changes: Verify power/toughness changes are applied
- Ability changes: Verify abilities are added/removed correctly
- Type changes: Verify type changes are applied correctly
- Color changes: Verify color changes are applied correctly
Control Effects
- Control changes: Verify control changes hands correctly
- Duration: Verify control returns at the right time
- Restrictions: Verify restrictions on controlled permanents
Verification Tools
Several tools assist in effect verification:
State Snapshots
Snapshots capture the game state before and after an effect:
#![allow(unused)] fn main() { // Example of using state snapshots #[test] fn verify_wrath_of_god_effect() { let mut scenario = TestScenario::new(); // Set up game state with creatures let player1 = scenario.add_player(20); let wrath = scenario.add_card_to_hand("Wrath of God", player1); scenario.add_card_to_battlefield("Grizzly Bears", player1); scenario.add_card_to_battlefield("Serra Angel", player1); // Take a snapshot before the effect let before_snapshot = scenario.create_snapshot(); // Execute the effect scenario.play_card(wrath, None); scenario.resolve_top_of_stack(); // Take a snapshot after the effect let after_snapshot = scenario.create_snapshot(); // Verify all creatures are destroyed assert_eq!( after_snapshot.count_permanents_by_type(player1, CardType::Creature), 0 ); // Verify the difference between snapshots let diff = before_snapshot.diff(&after_snapshot); assert!(diff.contains(SnapshotDiff::Destroyed { card_name: "Grizzly Bears".to_string() })); } }
Event Trackers
Event trackers monitor events triggered during effect resolution:
#![allow(unused)] fn main() { // Example of using event trackers #[test] fn verify_lightning_helix_effect() { let mut scenario = TestScenario::new(); // Set up the test let player1 = scenario.add_player(20); let player2 = scenario.add_player(20); let helix = scenario.add_card_to_hand("Lightning Helix", player1); // Create event trackers let mut damage_events = scenario.track_events::<DamageEvent>(); let mut life_gain_events = scenario.track_events::<LifeGainEvent>(); // Execute the effect scenario.play_card(helix, Some(player2)); scenario.resolve_top_of_stack(); // Verify damage event assert_eq!(damage_events.count(), 1); let damage_event = damage_events.last().unwrap(); assert_eq!(damage_event.amount, 3); assert_eq!(damage_event.target, player2); // Verify life gain event assert_eq!(life_gain_events.count(), 1); let life_gain_event = life_gain_events.last().unwrap(); assert_eq!(life_gain_event.amount, 3); assert_eq!(life_gain_event.player, player1); } }
Rules Oracle
The rules oracle verifies that effects comply with the MTG comprehensive rules:
#![allow(unused)] fn main() { // Example of using the rules oracle #[test] fn verify_protection_effect() { let mut scenario = TestScenario::new(); // Set up the test let player1 = scenario.add_player(20); let player2 = scenario.add_player(20); let creature = scenario.add_card_to_battlefield("Grizzly Bears", player1); let protection = scenario.add_card_to_hand("Gods Willing", player1); let bolt = scenario.add_card_to_hand("Lightning Bolt", player2); // Give protection from red scenario.play_card(protection, Some(creature)); scenario.choose_option("Red"); // Choose red for protection scenario.resolve_top_of_stack(); // Try to bolt the protected creature scenario.play_card(bolt, Some(creature)); // Verify with rules oracle let oracle_result = scenario.check_rules_compliance(); assert!(oracle_result.has_rule_violation(RuleViolation::InvalidTarget)); } }
Continuous Integration
Effect verification is integrated into the CI/CD pipeline:
- Automated testing: All effect tests run on each commit
- Regression detection: Changes that break effects are caught
- Coverage tracking: Ensures all effects are tested
- Performance monitoring: Tracks effect resolution performance
Related Documentation
- Interaction Testing: Testing interactions between effects
- Snapshot Testing: Using snapshots for testing
- Card Effects: How card effects are implemented
Interaction Testing
This document outlines the methodologies and tools used to test interactions between cards in Rummage. Interaction testing is crucial for ensuring that complex card combinations work correctly according to Magic: The Gathering rules.
Interaction Complexity
Card interactions in Magic: The Gathering can be extremely complex due to:
- Layering system: Effects apply in a specific order
- Timing rules: Effects happen at specific times
- Replacement effects: Effects that replace other effects
- Prevention effects: Effects that prevent other effects
- Triggered abilities: Abilities that trigger based on events
- State-based actions: Automatic game rules
Testing Approach
Interaction testing follows a systematic approach:
- Identify interaction pairs: Determine which cards interact
- Document expected behavior: Define how the interaction should work
- Create test scenarios: Design scenarios that test the interaction
- Execute tests: Run the tests and capture results
- Verify outcomes: Compare results to expected behavior
Interaction Test Framework
The interaction test framework provides tools for testing card interactions:
#![allow(unused)] fn main() { // Example of testing card interactions #[test] fn test_humility_and_opalescence_interaction() { let mut scenario = TestScenario::new(); // Set up players let player = scenario.add_player(20); // Add the interacting cards let humility = scenario.add_card_to_battlefield("Humility", player); let opalescence = scenario.add_card_to_battlefield("Opalescence", player); // Add a test enchantment let test_enchantment = scenario.add_card_to_battlefield("Pacifism", player); // Verify the interaction effects // 1. Check that Humility is a 4/4 creature with no abilities let humility_card = scenario.get_permanent(humility); assert_eq!(humility_card.power, 4); assert_eq!(humility_card.toughness, 4); assert!(humility_card.has_no_abilities()); // 2. Check that Opalescence is a 4/4 creature with no abilities let opalescence_card = scenario.get_permanent(opalescence); assert_eq!(opalescence_card.power, 4); assert_eq!(opalescence_card.toughness, 4); assert!(opalescence_card.has_no_abilities()); // 3. Check that Pacifism is a 4/4 creature with no abilities let pacifism_card = scenario.get_permanent(test_enchantment); assert_eq!(pacifism_card.power, 4); assert_eq!(pacifism_card.toughness, 4); assert!(pacifism_card.has_no_abilities()); // 4. Check that creatures on the battlefield are not affected by Pacifism let test_creature = scenario.add_card_to_battlefield("Grizzly Bears", player); let creature_card = scenario.get_permanent(test_creature); assert!(creature_card.can_attack()); } }
Interaction Categories
Different categories of interactions require specific testing approaches:
Layer Interactions
Testing interactions between effects in different layers:
#![allow(unused)] fn main() { #[test] fn test_layer_interactions() { let mut scenario = TestScenario::new(); let player = scenario.add_player(20); // Layer 4 (Type) and Layer 7 (P/T) interaction let bear = scenario.add_card_to_battlefield("Grizzly Bears", player); let mutation = scenario.add_card_to_hand("Artificial Evolution", player); let giant_growth = scenario.add_card_to_hand("Giant Growth", player); // Change creature type (Layer 4) scenario.play_card(mutation, Some(bear)); scenario.choose_text("Bear"); scenario.choose_text("Elephant"); scenario.resolve_top_of_stack(); // Boost power/toughness (Layer 7) scenario.play_card(giant_growth, Some(bear)); scenario.resolve_top_of_stack(); // Verify the creature is now a 5/5 Elephant let bear_card = scenario.get_permanent(bear); assert_eq!(bear_card.power, 5); assert_eq!(bear_card.toughness, 5); assert!(bear_card.type_line.contains("Elephant")); assert!(!bear_card.type_line.contains("Bear")); } }
Timing Interactions
Testing interactions with specific timing requirements:
#![allow(unused)] fn main() { #[test] fn test_timing_interactions() { let mut scenario = TestScenario::new(); let player1 = scenario.add_player(20); let player2 = scenario.add_player(20); // Set up cards let creature = scenario.add_card_to_battlefield("Grizzly Bears", player1); let counterspell = scenario.add_card_to_hand("Counterspell", player2); let bolt = scenario.add_card_to_hand("Lightning Bolt", player1); // Player 1 casts Lightning Bolt scenario.play_card(bolt, Some(player2)); // Player 2 responds with Counterspell scenario.pass_priority(player1); scenario.play_card(counterspell, Some(bolt)); // Resolve the stack scenario.resolve_stack(); // Verify Counterspell countered Lightning Bolt assert!(scenario.is_in_zone(bolt, Zone::Graveyard)); assert!(scenario.is_in_zone(counterspell, Zone::Graveyard)); assert_eq!(scenario.get_player_life(player2), 20); // Bolt was countered } }
Replacement Effect Interactions
Testing interactions between replacement effects:
#![allow(unused)] fn main() { #[test] fn test_replacement_effect_interactions() { let mut scenario = TestScenario::new(); let player = scenario.add_player(20); // Set up cards let creature = scenario.add_card_to_battlefield("Grizzly Bears", player); let prevention = scenario.add_card_to_battlefield("Circle of Protection: Red", player); let redirection = scenario.add_card_to_battlefield("Deflecting Palm", player); let bolt = scenario.add_card_to_hand("Lightning Bolt", player); // Activate Circle of Protection scenario.activate_ability(prevention, 0); scenario.pay_mana("{1}"); scenario.resolve_top_of_stack(); // Cast Lightning Bolt scenario.play_card(bolt, Some(player)); // Choose which replacement effect to apply first scenario.choose_replacement_effect(redirection); // Resolve the stack scenario.resolve_stack(); // Verify Deflecting Palm redirected the damage assert_eq!(scenario.get_player_life(player), 17); // Took 3 damage from redirection } }
Interaction Test Matrix
Complex interactions are organized in a test matrix:
- Row cards: First card in the interaction
- Column cards: Second card in the interaction
- Cell tests: Tests for the specific interaction
This ensures comprehensive coverage of card interactions.
Automated Interaction Discovery
The system can automatically discover potential interactions:
#![allow(unused)] fn main() { // Example of automated interaction discovery #[test] fn discover_interactions() { let interaction_finder = InteractionFinder::new(); // Find cards that interact with "Humility" let interactions = interaction_finder.find_interactions("Humility"); // Verify known interactions are discovered assert!(interactions.contains("Opalescence")); assert!(interactions.contains("Nature's Revolt")); assert!(interactions.contains("Dryad Arbor")); // Generate tests for each interaction for interaction in interactions { let test = interaction_finder.generate_test("Humility", &interaction); test.run(); } } }
Edge Case Testing
Interaction testing includes edge cases:
- Multiple simultaneous effects: Many effects applying at once
- Circular dependencies: Effects that depend on each other
- Priority edge cases: Complex priority passing scenarios
- Zone transition timing: Effects during zone transitions
Related Documentation
- Effect Verification: Testing individual card effects
- Complex Interactions: How complex interactions are implemented
- Layering System: Rules for effect layering
Card Interaction Testing
This document explains the approach for testing card interactions in Rummage. It covers how to create robust tests for card effects, interactions between cards, and complex game scenarios.
Testing Framework
Rummage provides a flexible testing framework centered around the TestScenario
struct, which simplifies setting up test environments for card interactions. This framework allows developers to:
- Create players with custom life totals
- Add cards to specific zones (hand, battlefield, graveyard, etc.)
- Play cards, including targeting
- Resolve the stack
- Verify game state after actions
Example Test
Here's a simple example of testing a Lightning Bolt:
#![allow(unused)] fn main() { #[test] fn lightning_bolt_deals_3_damage() { let mut test = TestScenario::new(); let player1 = test.add_player(20); let player2 = test.add_player(20); let bolt = test.add_card_to_hand("Lightning Bolt", player1); // Play Lightning Bolt targeting player2 test.play_card(bolt, Some(player2)); test.resolve_top_of_stack(); // Verify player2 lost 3 life assert_eq!(test.get_player_life(player2), 17); } }
Testing Complex Interactions
For complex card interactions, the testing framework supports:
- Stack Interactions: Testing cards that interact with the stack, like counterspells
- Zone Transitions: Verifying cards move between zones correctly
- Targeting: Testing complex targeting requirements and restrictions
- State-Based Actions: Verifying state-based actions resolve correctly
Example of testing a counterspell:
#![allow(unused)] fn main() { #[test] fn counterspell_counters_lightning_bolt() { let mut test = TestScenario::new(); let player1 = test.add_player(20); let player2 = test.add_player(20); let bolt = test.add_card_to_hand("Lightning Bolt", player1); let counterspell = test.add_card_to_hand("Counterspell", player2); // Play Lightning Bolt targeting player2 test.play_card(bolt, Some(player2)); // In response, player2 casts Counterspell targeting Lightning Bolt test.play_card(counterspell, Some(bolt)); // Resolve the stack from top to bottom test.resolve_top_of_stack(); // Counterspell resolves test.resolve_top_of_stack(); // Lightning Bolt tries to resolve but was countered // Verify player2 still has 20 life (Lightning Bolt was countered) assert_eq!(test.get_player_life(player2), 20); } }
Best Practices
When writing tests for card interactions, follow these best practices:
- Test the rule, not the implementation: Focus on testing the expected behavior according to MTG rules, not implementation details
- Cover edge cases: Test unusual interactions and edge cases
- Test interactions with different card types: Ensure cards interact correctly with different types (creatures, instants, etc.)
- Use realistic scenarios: Create test scenarios that mimic real game situations
- Document test intent: Clearly document what each test is verifying
Extending the Testing Framework
The testing framework is designed to be extensible. To add support for testing new card mechanics:
- Add new methods to the
TestScenario
struct - Implement simulation of the new mechanic
- Add verification methods to check the result
Future Enhancements
The testing framework is still under development. Planned enhancements include:
- Support for more complex targeting scenarios
- Better simulation of priority and the stack
- Support for testing multiplayer interactions
- Integration with snapshot testing
Related Documentation
- Effect Verification: How card effects are verified
- Interaction Testing: Testing complex card interactions
- Snapshot Testing: Using snapshots for card testing
- End-to-End Testing: Testing cards in full game scenarios
UI Integration
This document outlines how the card systems integrate with the user interface in Rummage, ensuring a seamless and intuitive player experience.
Integration Architecture
The card systems and UI are integrated through a well-defined interface:
- Card representations: Visual representations of cards
- Interaction handlers: Systems that handle UI interactions with cards
- Event propagation: Bidirectional event flow between UI and game logic
- State synchronization: Keeping UI and game state in sync
Card Visualization
Cards are visualized in the UI through:
- Card renderer: Renders cards based on their properties
- State indicators: Visual indicators for card states (tapped, summoning sick, etc.)
- Counter visualization: Display of counters on cards
- Effect visualization: Visual effects for active abilities
Card Interactions
The UI supports various card interactions:
- Dragging cards: Moving cards between zones
- Clicking cards: Selecting or activating cards
- Hovering cards: Displaying detailed information
- Targeting: Selecting targets for spells and abilities
- Stacking: Managing stacks of cards in zones
Implementation Details
The integration uses Bevy's entity component system:
#![allow(unused)] fn main() { // Example of a system that handles card selection in the UI fn handle_card_selection( mouse_input: Res<Input<MouseButton>>, mut selected_card: ResMut<SelectedCard>, card_query: Query<(Entity, &GlobalTransform, &Card, &Interaction)>, ) { if mouse_input.just_pressed(MouseButton::Left) { for (entity, transform, card, interaction) in card_query.iter() { if matches!(interaction, Interaction::Clicked) { selected_card.entity = Some(entity); // Trigger UI update for selection } } } } }
Zone Representation
Different zones are represented in the UI:
- Hand: Cards arranged in a fan or row
- Battlefield: Grid-based layout of permanent cards
- Graveyard: Stack of cards with access to view
- Exile: Similar to graveyard but visually distinct
- Stack: Visual representation of spells and abilities waiting to resolve
Responsiveness
The UI adapts to different situations:
- Different screen sizes: Layouts adjust to available space
- Varying card counts: Handles different numbers of cards in zones
- Game state changes: Updates in response to game state changes
Accessibility Considerations
The integration includes accessibility features:
- Color blindness support: Uses patterns and symbols in addition to colors
- Screen reader compatibility: Cards and states have text descriptions
- Keyboard navigation: Full functionality without mouse
- Customizable settings: Adjustable text size, contrast, etc.
Performance Optimization
The UI integration is optimized for performance:
- Culling: Only render visible cards
- Level of detail: Simplified representations for distant or small cards
- Asset management: Efficient loading and unloading of card assets
- Batched rendering: Group similar cards for efficient rendering
Related Documentation
- Card Rendering: How cards are visually rendered
- Game UI System: The overall UI system
- Card Selection: UI for selecting cards
- Drag and Drop: UI for dragging cards
- Targeting System: UI for targeting
Game UI System Documentation
This section provides a comprehensive overview of the in-game user interface systems for Rummage's Commander format Magic: The Gathering implementation.
Table of Contents
- Overview
- Key Components
- Implementation Status
- Integration with Card Systems
- Integration with Networking
Overview
The Game UI system is responsible for rendering the game state, facilitating player interactions, and providing visual feedback. Built using Bevy's Entity Component System (ECS) architecture, the UI serves as the visual representation layer that connects the player to the underlying game mechanics.
Our UI design philosophy focuses on three key principles:
- Clarity: Making game state clearly visible and understandable
- Usability: Providing intuitive interactions for complex game mechanics
- Immersion: Creating a visually appealing and thematically consistent experience
The UI system functions as a bridge between the player and the game engine, translating ECS component data into visual elements and user inputs into game actions. It maintains its own set of UI-specific entities and components that represent the visual state of the game, which are updated in response to changes in the core game state.
For a more detailed overview, see the overview document.
Key Components
The Game UI system consists of the following major components:
-
- Playmat
- Command Zone
- Battlefield
- Player Zones
- Stack Visualization
-
- Card Rendering
- Card States (Tapped, Exiled, etc.)
- Card Animations
- State Transitions
-
- Card Selection
- Drag and Drop
- Action Menus
- Targeting System
- Input Validation
-
- Game Log
- Phase Indicators
- Priority Visualization
- Tooltips and Helpers
- Rules References
-
- Turn Visualization
- Phase Transitions
- Priority Passing
- Timer Indicators
-
- Modal Dialogs
- Choice Interfaces
- Decision Points
- Triggered Ability Selections
-
- Player Positioning
- Visibility Controls
- Opponent Actions
- Synchronization Indicators
-
- Battlefield Layout
- Card Stacking
- Zone Visualization
- Spatial Management
-
- Background Design
- Zone Demarcation
- Visual Themes
- Customization Options
-
- Message Display
- Input Interface
- Emotes
- Communication Filters
-
- Player Avatars
- Avatar Selection
- Custom Avatar Support
- Visual Feedback
-
- Unit Testing UI Components
- Integration Testing
- UI Automation Testing
- Visual Regression Testing
Implementation Status
This documentation represents the design and implementation of the Game UI system. Components are marked as follows:
Component | Status | Description |
---|---|---|
Core UI Framework | ✅ | Basic UI rendering and interaction system |
Card Visualization | ✅ | Rendering cards and their states |
Battlefield Layout | ✅ | Arrangement of permanents on the battlefield |
Hand Interface | ✅ | Player's hand visualization and interaction |
Stack Visualization | 🔄 | Visual representation of the spell stack |
Command Zone | 🔄 | Interface for commanders and command zone abilities |
Phase/Turn Indicators | 🔄 | Visual indicators for game phases and turns |
Player Information | ✅ | Display of player life, mana, and other stats |
Targeting System | 🔄 | System for selecting targets for spells and abilities |
Decision Interfaces | ⚠️ | Interfaces for player decisions and choices |
Chat System | ⚠️ | In-game communication system |
Settings Menu | ⚠️ | Interface for adjusting game settings |
Legend:
- ✅ Implemented and tested
- 🔄 In progress
- ⚠️ Planned but not yet implemented
Integration with Card Systems
The Game UI system works in close collaboration with the Card Systems to transform the core game data into interactive visual elements:
Visualization Pipeline
The UI receives card data from the Card Systems and renders it according to the current game state:
- Card entities from the game engine are mapped to visual representations
- The UI listens for changes to card components (like tapped status or counters)
- Visual effects are applied based on card state changes
Interaction Translation
User interactions with card visuals are translated back into game engine commands:
- Dragging a card triggers zone transfer requests in the game engine
- Clicking on cards selects them for targeting or activation
- Right-clicking shows context-sensitive actions relevant to the card
Special Card Rendering
Some cards require special UI handling:
- Modal cards present choice interfaces
- Split cards have multiple faces to visualize
- Transformed cards need to show both states
- Cards with counters display them visually
For detailed information on how cards are represented in the data model, see the Card Database documentation.
Integration with Networking
In multiplayer games, the UI system coordinates with the Networking module to ensure consistent visual representation across all clients:
State Synchronization
The UI responds to network synchronization events:
- Updates card positions and states based on received snapshots
- Provides visual indicators for network operations (opponent thinking, sync status)
- Handles delayed or out-of-order updates gracefully
Action Broadcasting
When a player performs an action, the UI:
- Shows a local preview of the expected result
- Sends the action to the server via the networking system
- Updates the display when confirmation is received
- Handles conflicts if server state differs from prediction
Latency Compensation
To provide a responsive feel despite network latency:
- The UI implements client-side prediction for common actions
- Visual feedback indicates when actions are pending confirmation
- Animated transitions smooth out state updates from the server
For more detailed information on how the UI integrates with networked gameplay, see the Gameplay Networking documentation.
For detailed information on specific UI components, please refer to the respective sections listed above.
Game UI System Overview
This document provides a high-level overview of the user interface system for Rummage's Commander format game. For more detailed information on specific components, please see the related documentation files.
UI Architecture Overview
The Rummage game UI is built using Bevy's Entity Component System (ECS) architecture, with a clear separation of concerns between game logic and visual presentation. The UI follows a layered approach to ensure clean organization, efficient rendering, and maintainable code.
Key Components
The UI system uses non-deprecated Bevy 0.15.x components:
Node
for layout containers (replacing deprecatedNodeBundle
)Text2d
for text display (replacing deprecatedText2dBundle
)Sprite
for images (replacing deprecatedSpriteBundle
)Button
for interactive elementsRenderLayers
for visibility control
Layers
The UI architecture is organized into distinct render layers as defined in src/camera/components.rs
:
#![allow(unused)] fn main() { #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] pub enum AppLayer { #[default] Shared, // Layer 0: Shared elements visible to all cameras Background, // Layer 1: Game background elements GameWorld, // Layer 2: Game world elements Cards, // Layer 3: Card entities GameUI, // Layer 4: In-game UI elements Effects, // Layer 5: Visual effects Overlay, // Layer 6: Game overlays Menu, // Layer 7: Menu elements Popup, // Layer 8: Popup dialogs Debug, // Layer 9: Debug visuals DebugText, // Layer 10: Debug text DebugGizmo, // Layer 11: Debug gizmos Wireframe, // Layer 12: Wireframe visualization Game = 31, // Layer 31: Legacy game layer (backward compatibility) } }
These layers enable precise control over which UI elements are visible in different game states.
Design Philosophy
The Rummage UI design follows these key principles:
- Clarity First: Game state information must be clear and unambiguous
- Accessibility: UI should be usable by players with diverse accessibility needs
- Intuitive Interaction: Similar to physical Magic, but enhanced by digital capabilities
- Visual Hierarchy: Important elements stand out through size, color, and animation
- Responsive Design: Adapts to different screen sizes and orientations
- Performance: Optimized rendering for smooth gameplay
Core UI Systems
1. Layout Management
The game employs a flexible layout system that organizes UI elements into zones:
- Player Zones: Hand, battlefield, graveyard, exile, library positions
- Shared Zones: Stack, command zone, and turn structure indicators
- Information Zones: Game log, phase indicators, and supplementary information
Layouts adjust dynamically based on player count, screen size, and game state.
2. Interaction System
The interaction system handles:
- Drag and Drop: Moving cards between zones, with preview of valid targets
- Targeting: Selecting targets for spells and abilities
- Context Menus: Right-click (or long press) for additional card actions
- Hotkeys: Keyboard shortcuts for common actions
3. State Visualization
The UI visualizes game state through:
- Card Transformations: Visual representation of card states (tapped, attacking, etc.)
- Animations: Visual feedback for game events and transitions
- Effects: Particles and visual flourishes for special events
4. Information Display
Information is conveyed through:
- Text: Game text, rules text, and reminders
- Icons: Visual representation of card types, abilities, and states
- Tooltips: Contextual help and explanations
- Game Log: Record of game events and actions
Integration with Game Logic
The UI system integrates with the game logic through:
- Systems: Reacting to game state changes and updating visuals
- Events: Processing user interactions and converting them to game actions
- Resources: Accessing shared game state to reflect in the UI
Technical Implementation
The UI system is implemented using Bevy's ECS pattern:
- Components: Define UI element properties and behaviors
- Systems: Update and render UI elements based on game state
- Resources: Store shared UI state information
- Events: Handle user input and trigger UI updates
Accessibility Features
The game includes several accessibility features:
- Color Blind Modes: Alternative color schemes for color-blind players
- Text Scaling: Adjustable text size for readability
- Screen Reader Support: Critical game information is accessible via screen readers
- Keyboard Controls: Full game functionality available through keyboard
- Animation Reduction: Option to reduce or disable animations
Performance Considerations
The UI system is optimized for performance through:
- Element Pooling: Reusing UI elements to reduce allocation
- Batched Rendering: Minimizing draw calls
- Culling: Only rendering visible elements
- Async Loading: Loading assets in the background
Future Enhancements
Planned enhancements to the UI system include:
- Custom Themes: Player-selectable UI themes
- UI Animations: Enhanced visual feedback
- Mobile Optimization: Touch-specific controls and layouts
- VR Mode: Virtual reality support for immersive gameplay
Game UI Layout Components
This document describes the layout components used in the Rummage game interface, focusing on the spatial organization and structure of the UI elements.
Table of Contents
Layout Philosophy
The Rummage game layout follows these principles:
- Spatial Clarity: Each game zone has a distinct location
- Player Symmetry: Player areas are arranged consistently
- Focus on Active Elements: Important game elements receive visual prominence
- Efficient Screen Usage: Maximize the viewing area for the battlefield
- Logical Grouping: Related UI elements are positioned near each other
Layout Overview
The game UI is structured around a central battlefield with player zones positioned around it:
┌─────────────────────────────────────────────────────────────────┐
│ OPPONENT INFORMATION │
├───────────┬─────────────────────────────────────┬───────────────┤
│ │ │ │
│ OPPONENT │ │ OPPONENT │
│ EXILE │ │ GRAVEYARD │
│ │ │ │
├───────────┤ ├───────────────┤
│ │ │ │
│ │ │ │
│ OPPONENT │ │ STACK │
│ LIBRARY │ BATTLEFIELD │ AREA │
│ │ │ │
│ │ │ │
├───────────┤ ├───────────────┤
│ │ │ │
│ PLAYER │ │ PLAYER │
│ LIBRARY │ │ GRAVEYARD │
│ │ │ │
├───────────┼─────────────────────────────────────┼───────────────┤
│ │ PLAYER HAND │ COMMAND │
│ PLAYER ├─────────────────────────────────────┤ ZONE │
│ EXILE │ PLAYER INFORMATION │ │
└───────────┴─────────────────────────────────────┴───────────────┘
This layout adjusts for multiplayer games, positioning all opponents around the battlefield.
Responsive Adaptations
The layout adapts to different screen sizes and orientations:
Desktop Layout
- Horizontal layout with wide battlefield
- Hand cards displayed horizontally
- Full information displays
Tablet Layout
- Similar to desktop with slightly compressed zones
- Touch-friendly spacing
- Collapsible information panels
Mobile Layout
- Vertical orientation with stacked zones
- Scrollable battlefield
- Collapsible hand and information displays
- Gesture-based zone navigation
Dynamic Player Positioning
For multiplayer games (3-4 players), the layout adjusts to position player zones around the battlefield:
Four Player Layout:
┌───────────┬─────────────────────────────┬───────────┐
│ │ OPPONENT 2 │ │
│ OPPONENT2 │ ┌───────────────────────┐ │ OPPONENT2 │
│ ZONES │ │ │ │ ZONES │
├───────────┤ │ │ ├───────────┤
│ │ │ │ │ │
│ OPPONENT1 │ │ │ │ OPPONENT3 │
│ ZONES │ │ BATTLEFIELD │ │ ZONES │
│ │ │ │ │ │
│ │ │ │ │ │
│ │ │ │ │ │
├───────────┤ │ │ ├───────────┤
│ COMMAND │ └───────────────────────┘ │ STACK │
│ ZONE │ PLAYER ZONES │ AREA │
└───────────┴─────────────────────────────┴───────────┘
Zone Transitions
Zones provide visual cues during transitions:
- Highlight: Active zones highlight during player turns
- Animation: Cards animate when moving between zones
- Focusing: Relevant zones enlarge when cards within them are targeted or selected
Implementation Details
Layout components are implemented using Bevy's UI system with nested Node components:
#![allow(unused)] fn main() { // Example layout container for a player's battlefield area commands .spawn(( Node { width: Val::Percent(100.0), height: Val::Percent(40.0), flex_direction: FlexDirection::Column, align_items: AlignItems::Center, justify_content: JustifyContent::Center, ..default() }, BattlefieldContainer, AppLayer::GameUI.layer(), )) .with_children(|parent| { // Individual battlefield row containers // ... }); }
Layout Managers
Layout position is managed by dedicated systems that:
- Calculate zone positions based on player count and screen size
- Adjust UI element scale to maintain usability
- Reposition elements in response to game state changes
- Handle element focus and highlighting
The primary layout management systems include:
update_layout_for_player_count
: Adjusts layout based on number of playersupdate_responsive_layout
: Adapts layout to screen size changesposition_cards_in_zones
: Manages card positioning within zones
Each layout component has dedicated documentation explaining its specific implementation.
Layout System
The Layout System is responsible for organizing and positioning UI elements in the Rummage game interface. It provides a flexible foundation for arranging game elements across different screen sizes and orientations.
Core Architecture
The layout system is built on Bevy's native UI components, using a component-based approach:
#![allow(unused)] fn main() { #[derive(Component)] pub struct LayoutContainer { pub container_type: ContainerType, pub flex_direction: FlexDirection, pub size: Vec2, pub padding: UiRect, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ContainerType { Root, Player, Battlefield, Hand, Stack, CommandZone, InfoPanel, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FlexDirection { Row, Column, RowReverse, ColumnReverse, } }
Hierarchy Structure
The UI is organized in a hierarchical structure:
- Root Container: The main layout container that spans the entire screen
- Zone Containers: Containers for each game zone (battlefield, hand, graveyard, etc.)
- Element Containers: Containers for specific UI elements (cards, controls, info panels)
- Individual Elements: The actual UI components that players interact with
Layout Calculation
Layout positions are calculated using a combination of:
- Absolute Positioning: Fixed positions for certain UI elements
- Flex Layout: Flexible positioning based on container size and available space
- Grid Layout: Grid-based positioning for elements like cards on the battlefield
- Anchoring: Elements can be anchored to edges or centers of their containers
#![allow(unused)] fn main() { fn calculate_layout( mut query: Query<(&mut Transform, &LayoutElement, &Parent)>, containers: Query<&LayoutContainer>, ) { // Implementation details for calculating positions } }
Responsive Design
The layout system adapts to different screen sizes and aspect ratios:
- Breakpoints: Different layouts are used at specific screen size breakpoints
- Scale Factors: UI elements scale based on screen resolution
- Prioritization: Critical elements are prioritized in limited space
- Overflow Handling: Scrolling or pagination for overflow content
#![allow(unused)] fn main() { fn adapt_to_screen_size( mut layout_query: Query<&mut LayoutContainer>, windows: Res<Windows>, ) { // Implementation details for screen adaptation } }
Zone-specific Layouts
Each game zone has specialized layout behavior:
Battlefield Layout
- Grid-based layout for permanents
- Automatic card grouping by type
- Dynamic spacing based on card count
- Support for attacking/blocking visualization
Hand Layout
- Fan or straight-line layouts
- Automatic card reorganization
- Vertical position adjustment during card selection
- Overflow handling for large hands
Stack Layout
- Cascading layout for spells and abilities
- Visual nesting for complex interactions
- Animation support for stack resolution
Integration with Card Visualization
The layout system works closely with the Card Visualization system:
- Positions cards according to their game state
- Handles z-ordering for overlapping cards
- Provides layout input for card animations
- Adjusts spacing based on card visibility needs
Plugin Integration
The layout system is implemented as a Bevy plugin:
#![allow(unused)] fn main() { pub struct LayoutPlugin; impl Plugin for LayoutPlugin { fn build(&self, app: &mut App) { app .add_systems(Startup, setup_layout) .add_systems( Update, ( calculate_layout, adapt_to_screen_size, handle_layout_changes, ), ); } } }
Performance Considerations
The layout system is optimized for performance:
- Batched layout calculations to minimize CPU usage
- Layout caching to prevent unnecessary recalculations
- Hierarchical dirty flagging to only update changed layouts
- Custom layout algorithms for common patterns
Example: Battlefield Layout
#![allow(unused)] fn main() { fn create_battlefield_layout(commands: &mut Commands, ui_materials: &UiMaterials) { commands .spawn(( SpatialBundle::default(), LayoutContainer { container_type: ContainerType::Battlefield, flex_direction: FlexDirection::Column, size: Vec2::new(1600.0, 900.0), padding: UiRect::all(Val::Px(10.0)), }, Name::new("Battlefield Container"), )) .with_children(|parent| { // Create player areas within battlefield for player_index in 0..4 { create_player_battlefield_area(parent, ui_materials, player_index); } // Create center area for shared elements create_center_battlefield_area(parent, ui_materials); }); } }
Customization
The layout system supports customization through:
- Layout Themes: Pre-defined layout configurations
- User Preferences: User-adjustable layout options
- Dynamic Adaptation: Runtime layout adjustments based on game state
For more details on how layouts integrate with other UI systems, see Zone Visualization and Responsive Design.
Responsive Design
This document describes how the Rummage game UI adapts to different screen sizes, aspect ratios, and device types to provide an optimal user experience across platforms.
Responsive Architecture
The responsive design system is built on several key components:
- Breakpoint System: Defines screen size thresholds for layout changes
- Flexible Layout: Uses relative sizing and positioning
- Dynamic Scaling: Adjusts UI element sizes based on screen dimensions
- Priority-based Visibility: Shows/hides elements based on available space
- Orientation Handling: Adapts to landscape and portrait orientations
Breakpoint System
The UI defines several breakpoints that trigger layout changes:
#![allow(unused)] fn main() { pub enum ScreenBreakpoint { Mobile, // < 768px width Tablet, // 768px - 1279px width Desktop, // 1280px - 1919px width LargeDesktop // >= 1920px width } }
Each breakpoint has associated layout configurations that adjust:
- Element sizes and positions
- Information density
- Touch target sizes
- Visibility of secondary elements
Layout Adaptation
Desktop Layout
On larger screens, the UI maximizes information visibility:
- Full battlefield view with minimal scrolling
- Expanded card hand display
- Detailed player information panels
- Side-by-side arrangement of game zones
#![allow(unused)] fn main() { fn configure_desktop_layout( mut layout_query: Query<&mut LayoutContainer, With<RootContainer>>, ) { for mut container in layout_query.iter_mut() { container.flex_direction = FlexDirection::Row; container.size = Vec2::new(1920.0, 1080.0); // Additional desktop-specific configuration } } }
Tablet Layout
On medium-sized screens, the UI balances information and usability:
- Slightly compressed game zones
- Collapsible information panels
- Touch-friendly spacing and controls
- Optional side panels that can be toggled
Mobile Layout
On small screens, the UI prioritizes core gameplay elements:
- Vertically stacked layout
- Swipeable/scrollable zones
- Collapsible hand that expands on tap
- Minimized information displays with expandable details
- Larger touch targets for better usability
#![allow(unused)] fn main() { fn configure_mobile_layout( mut layout_query: Query<&mut LayoutContainer, With<RootContainer>>, ) { for mut container in layout_query.iter_mut() { container.flex_direction = FlexDirection::Column; container.size = Vec2::new(390.0, 844.0); // iPhone 12 Pro dimensions as example // Additional mobile-specific configuration } } }
Dynamic Element Scaling
UI elements scale proportionally based on screen size:
- Cards maintain aspect ratio while adjusting overall size
- Text scales to remain readable on all devices
- Touch targets maintain minimum size for usability
- Spacing adjusts to prevent crowding on small screens
#![allow(unused)] fn main() { fn scale_ui_elements( mut card_query: Query<&mut Transform, With<CardVisual>>, screen_size: Res<ScreenDimensions>, ) { let scale_factor = calculate_scale_factor(screen_size.width, screen_size.height); for mut transform in card_query.iter_mut() { transform.scale = Vec3::splat(scale_factor); } } }
Orientation Handling
The UI adapts to device orientation changes:
Landscape Orientation
- Horizontal layout with wide battlefield
- Hand displayed along bottom edge
- Player information in corners
Portrait Orientation
- Vertical layout with stacked elements
- Hand displayed along bottom edge
- Battlefield takes central position
- Player information at top and bottom
#![allow(unused)] fn main() { fn handle_orientation_change( mut orientation_events: EventReader<OrientationChangedEvent>, mut layout_query: Query<&mut LayoutContainer, With<RootContainer>>, ) { for event in orientation_events.read() { match event.orientation { Orientation::Landscape => configure_landscape_layout(&mut layout_query), Orientation::Portrait => configure_portrait_layout(&mut layout_query), } } } }
Priority-based Element Visibility
When screen space is limited, elements are shown or hidden based on priority:
- Essential: Always visible (battlefield, hand, stack)
- Important: Visible when space allows (player info, graveyards)
- Secondary: Collapsed by default, expandable on demand (exile, detailed card info)
- Optional: Hidden on small screens (decorative elements, full game log)
Implementation
The responsive design system is implemented through several components:
#![allow(unused)] fn main() { // Tracks current screen dimensions and orientation #[derive(Resource)] pub struct ScreenDimensions { pub width: f32, pub height: f32, pub orientation: Orientation, pub breakpoint: ScreenBreakpoint, } // System to detect screen changes fn detect_screen_changes( mut screen_dimensions: ResMut<ScreenDimensions>, windows: Res<Windows>, mut orientation_events: EventWriter<OrientationChangedEvent>, ) { // Implementation details } }
Testing
The responsive design is tested across multiple device profiles:
- Desktop monitors (various resolutions)
- Tablets (iPad, Android tablets)
- Mobile phones (iOS, Android)
- Ultrawide monitors
- Small laptops
Integration
The responsive design system integrates with:
- Layout System: Provides the foundation for responsive layouts
- Zone Visualization: Zones adapt based on screen size
- Card Visualization: Cards scale appropriately
For more details on specific UI components and how they adapt, see the relevant component documentation.
Zone Visualization
This document describes how different game zones are visually represented in the Rummage UI.
Zone Types
Magic: The Gathering defines several game zones, each with unique visualization requirements:
- Battlefield: Where permanents are played
- Hand: Cards held by a player
- Library: A player's deck
- Graveyard: Discard pile
- Exile: Cards removed from the game
- Stack: Spells and abilities waiting to resolve
- Command Zone: Special zone for commanders and emblems
Visual Representation
Each zone has a distinct visual style to help players quickly identify it:
Battlefield
- Layout: Grid-based layout with flexible positioning
- Background: Textured playmat appearance
- Borders: Subtle zone boundaries for each player's area
- Organization: Cards automatically group by type (creatures, lands, etc.)
#![allow(unused)] fn main() { fn create_battlefield_zone(commands: &mut Commands, materials: &UiMaterials) { commands.spawn(( SpatialBundle::default(), Node { background_color: materials.battlefield_background.clone(), ..default() }, BattlefieldZone, Name::new("Battlefield Zone"), )); } }
Hand
- Layout: Fan or straight-line arrangement
- Background: Semi-transparent panel
- Privacy: Only visible to the controlling player (except in spectator mode)
- Interaction: Cards rise when hovered
Library
- Visualization: Stack of cards showing only the back
- Counter: Numerical display of remaining cards
- Animation: Cards visibly draw from top
Graveyard
- Layout: Stacked with slight offset to show multiple cards
- Visibility: Top card always visible
- Interaction: Can be expanded to view all cards
Exile
- Visual Style: Distinct "removed from game" appearance
- Organization: Grouped by source when relevant
- Special Effects: Subtle visual effects to distinguish from other zones
Stack
- Layout: Cascading arrangement showing pending spells/abilities
- Order: Clear visual indication of resolution order
- Targeting: Visual connections to targets
- Animation: Items move as they resolve
Command Zone
- Prominence: Visually distinct and always accessible
- Commander Display: Shows commander card(s)
- Tax Counter: Visual indicator of commander tax
Zone Transitions
When cards move between zones, the transition is animated to provide visual feedback:
#![allow(unused)] fn main() { fn animate_zone_transition( commands: &mut Commands, card_entity: Entity, from_zone: Zone, to_zone: Zone, animation_assets: &AnimationAssets, ) { // Calculate start and end positions let start_pos = get_zone_position(from_zone); let end_pos = get_zone_position(to_zone); // Create animation sequence commands.entity(card_entity).insert(AnimationSequence { animations: vec![ Animation::new_position(start_pos, end_pos, Duration::from_millis(300)), Animation::new_scale(Vec3::splat(0.9), Vec3::ONE, Duration::from_millis(150)), ], on_complete: Some(ZoneTransitionComplete { zone: to_zone }), }); } }
Zone Interaction
Zones support various interactions:
- Click: Select the zone or expand it for more detail
- Drag-to: Move cards to the zone
- Drag-from: Take cards from the zone
- Right-click: Open zone-specific context menu
- Hover: Show additional information about the zone
Zone Components
Zones are implemented using several components:
#![allow(unused)] fn main() { #[derive(Component)] pub struct ZoneVisualization { pub zone_type: Zone, pub owner: Option<Entity>, pub expanded: bool, pub card_count: usize, pub layout_style: ZoneLayoutStyle, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ZoneLayoutStyle { Grid, Stack, Fan, Cascade, List, } }
Zone Systems
Several systems manage zone visualization:
- Zone Layout System: Positions cards within zones
- Zone Transition System: Handles card movements between zones
- Zone Interaction System: Processes player interactions with zones
- Zone Update System: Updates zone visuals based on game state
#![allow(unused)] fn main() { fn update_zone_visuals( mut zone_query: Query<(&mut ZoneVisualization, &Children)>, card_query: Query<Entity, With<Card>>, game_state: Res<GameState>, ) { for (mut zone, children) in zone_query.iter_mut() { // Update card count zone.card_count = children.iter() .filter(|child| card_query.contains(**child)) .count(); // Update visuals based on card count and zone type // ... } } }
Multiplayer Considerations
In multiplayer games, zones are arranged to clearly indicate ownership:
- Player Zones: Positioned relative to each player's seat
- Shared Zones: Positioned in neutral areas
- Opponent Zones: Scaled and positioned for visibility
Accessibility Features
Zone visualization includes accessibility considerations:
- Color Coding: Zones have distinct colors that work with colorblind modes
- Text Labels: Optional text labels for each zone
- Screen Reader Support: Zones announce their type and contents
- Keyboard Navigation: Zones can be selected and manipulated via keyboard
Integration
Zone visualization integrates with:
- Layout System: Zones are positioned by the layout system
- Card Visualization: Cards appear differently based on their zone
- Responsive Design: Zones adapt to different screen sizes
For more details on specific zone implementations, see the relevant game rules documentation.
Card Visualization
This section covers the systems and components used to visualize cards in the game UI. Card visualization is a critical part of the user experience, as cards are the primary objects players interact with.
Overview
The card visualization system is responsible for:
- Rendering cards with appropriate artwork, text, and stats
- Displaying different card states (tapped, flipped, face-down, etc.)
- Animating card movements and state changes
- Supporting responsive layout across different screen sizes
Key Components
- Card Rendering: How cards are visually represented
- Card States: How different card states are visualized
- Card Animations: How cards animate between states and positions
Implementation
Card visualization uses Bevy's rendering systems and components to create an efficient and visually appealing representation of Magic the Gathering cards.
Card Rendering
The card rendering system is responsible for visualizing Magic the Gathering cards in the game UI.
Card Components
Cards are rendered using several visual components:
- Card Frame: The border and background of the card, varying by card type
- Art Box: The image area displaying card artwork
- Text Box: The area containing card rules text
- Type Line: The line showing card types and subtypes
- Mana Cost: Symbols representing the card's casting cost
- Name Bar: The area displaying the card's name
- Stats Box: For creatures, the power/toughness display
Rendering Pipeline
Cards are rendered using Bevy's 2D rendering pipeline:
- Card data is loaded from the game engine
- Visual components are constructed as Bevy entities
- Sprites and text elements are positioned according to the card layout
- Materials and shaders are applied for visual effects
Optimizations
The rendering system uses several optimizations to maintain performance:
- Texture Atlases: Card symbols are packed into texture atlases
- Dynamic LOD: Cards further from the camera show simplified versions
- Batched Rendering: Similar cards use instanced rendering where possible
- Caching: Frequently used card layouts are cached
Integration
Card rendering integrates with several other systems:
- Card States: Visual representation changes based on card state
- Drag and Drop: Visual feedback during dragging
- Card Animations: Smooth transitions between visual states
Example Usage
#![allow(unused)] fn main() { // Create a card render entity commands.spawn(( SpriteBundle { sprite: Sprite { color: Color::WHITE, custom_size: Some(Vec2::new(CARD_WIDTH, CARD_HEIGHT)), ..default() }, transform: Transform::from_translation(Vec3::new(0.0, 0.0, 0.0)), ..default() }, CardRender { card_entity: card_entity, frame_type: FrameType::Creature, foil: false, }, )); }
For information on how cards integrate with the gameplay systems, see Card Integration.
Card States
This document describes how different card states are visually represented in the game UI.
Common Card States
Cards can be in various states during gameplay:
- Normal: The default state of a card
- Tapped: Rotated 90 degrees to indicate it has been used
- Flipped: Turned 180 degrees per specific card abilities
- Face-down: Showing the card back instead of the face
- Revealed: Temporarily shown to specific players
- Highlighted: Visually emphasized for selection or targeting
Visual Representation
Each state has specific visual cues:
- Tapped: 90-degree rotation with possible shader effects
- Flipped: 180-degree rotation along the Y-axis
- Face-down: Shows the card back texture
- Revealed: Temporarily elevated z-position with highlight effects
- Highlighted: Glow effect or outline based on the context
State Transitions
When a card changes state, it undergoes an animation to provide visual feedback:
- Tap/untap animations are smooth rotations
- Reveal animations include scaling and elevation changes
- Highlight animations use pulsing effects
For details on these animations, see Card Animations.
Integration
Card states integrate with:
- Card Rendering: The base rendering is modified by state
- Drag and Drop: Some states affect drag behavior
- Game Rules: States reflect game rule concepts
Implementation
Each state is managed through components and systems that modify the card's visual representation.
Card Animations
This document describes how cards animate between different states and positions in the game UI.
Types of Animations
The animation system supports several types of card animations:
- Movement: Cards moving between zones or positions
- State Change: Visual changes when a card's state changes
- Highlight: Temporary visual effects to draw attention
- Special Effects: Specific animations for card abilities or events
Animation System
Animations are implemented using Bevy's animation system:
- Tweening: Smooth transitions between states using easing functions
- Animation Tracks: Sequences of animations that can be chained
- Event-driven: Animations trigger in response to game events
Common Animations
Movement Animations
When cards move between zones:
- Draw: Cards animate from deck to hand
- Play: Cards animate from hand to battlefield
- Discard: Cards animate from hand to graveyard
- Shuffle: Cards animate into the library
State Change Animations
- Tap/Untap: Smooth rotation animation
- Flip: 3D rotation along the Y-axis
- Reveal: Card scales up briefly
- Transform: Special effect for double-faced cards
Integration
Card animations integrate with:
- Card Rendering: Animations modify the rendered card
- Card States: State changes trigger animations
- Drag and Drop: Dragging has specific movement animations
Implementation Example
#![allow(unused)] fn main() { // Example of creating a card draw animation fn animate_card_draw( commands: &mut Commands, card_entity: Entity, start_pos: Vec3, end_pos: Vec3, ) { commands.entity(card_entity).insert(AnimationSequence { animations: vec![ Animation::new( start_pos, end_pos, Duration::from_millis(300), EaseFunction::CubicOut, ), Animation::new_scale( Vec3::splat(0.9), Vec3::ONE, Duration::from_millis(150), EaseFunction::BackOut, ), ], on_complete: Some(AnimationComplete::CardDrawn), }); } }
For more information on how animations integrate with the game flow, see Game Flow Visualization.
Interaction Systems
This section covers the various interaction systems that allow players to interface with the game. These systems are designed to provide intuitive and responsive control over game elements.
Overview
The interaction systems in Rummage are built to be:
- Intuitive: Players should be able to understand how to interact with game elements without extensive tutorials
- Responsive: Interactions should feel snappy and provide appropriate feedback
- Accessible: Interactions should work across different input methods and with accessibility considerations
Key Interaction Systems
- Card Selection: How players select cards and receive visual feedback
- Drag and Drop: How players move cards and other game elements between zones
- Targeting System: How players select targets for spells and abilities
Implementation Details
Interaction systems are implemented using Bevy's event and component systems. Each interaction type is maintained as a separate module with its own components and systems, but they are designed to work together seamlessly.
Card Selection
The card selection system provides visual feedback and interaction mechanisms for selecting cards in the game.
Selection Modes
The system supports different selection modes:
- Single selection: Only one card can be selected at a time
- Multi-selection: Multiple cards can be selected simultaneously (e.g., for mass actions)
- Range selection: Cards within a range can be selected together
- Context-aware selection: Selection behavior changes based on game phase or action
Visual Feedback
When a card is selected, visual feedback is provided to the player through:
- Highlight effects around the card
- Change in elevation or z-index
- Animation effects
- Pulsing glow or outline with phase-appropriate colors
- Optional sound effects for accessibility
User Input Methods
Cards can be selected through various input methods:
- Mouse click: Standard selection method
- Shift+click: Add to current selection (multi-select)
- Ctrl+click: Toggle selection of individual card
- Click and drag: Select multiple cards by dragging a selection box
- Tab navigation: For keyboard-only control
- Touch: Single tap selects, double tap activates
Selection Context
Selection behavior adapts based on the current game context:
- Main phase: Selection allows for playing cards or activating abilities
- Combat phase: Selection highlights potential attackers or blockers
- Targeting phase: Selection restricted to valid targets
- Stack interaction: Selection focuses on spells or abilities on the stack
Integration
Card selection integrates with other systems:
- Drag and Drop: Selected cards can be dragged as a group
- Targeting System: Selected cards can be used as sources for abilities
- Game State: Selection states are tracked in the game state
- Card Effects: Selection can trigger card-specific effects
- Accessibility: Selection provides screen reader feedback
Implementation
The selection system uses Bevy's component and event system:
- Cards have a
Selectable
component - Selection state is managed through events
- Visual feedback is applied through transform and material changes
Component Structure
#![allow(unused)] fn main() { #[derive(Component)] pub struct Selectable { /// Whether the card is currently selected pub selected: bool, /// Whether the cursor is hovering over the card pub hover: bool, /// Optional grouping for multi-select operations pub selection_group: Option<u32>, /// History of selection for undo operations pub selection_history: Vec<SelectionState>, /// Custom highlight color for this selectable pub highlight_color: Option<Color>, } #[derive(Event)] pub struct CardSelectedEvent { pub entity: Entity, pub selected: bool, pub selection_mode: SelectionMode, } }
Selection System Implementation
The core selection system consists of several systems:
#![allow(unused)] fn main() { /// Process mouse selection inputs fn process_selection_input( mut commands: Commands, mouse_input: Res<Input<MouseButton>>, keyboard_input: Res<Input<KeyCode>>, mut card_query: Query<(Entity, &GlobalTransform, &mut Selectable)>, mut selection_events: EventWriter<CardSelectedEvent>, ) { // Implementation details } /// Update visual appearance based on selection state fn update_selection_visuals( mut commands: Commands, mut query: Query<(Entity, &Selectable, Option<&mut Transform>)>, time: Res<Time>, ) { // Implementation details } }
Usage Example
#![allow(unused)] fn main() { // Spawn a selectable card commands.spawn(( card_bundle, Selectable { selected: false, hover: false, selection_group: None, selection_history: Vec::new(), highlight_color: None, }, )); // System to detect selection and perform an action fn handle_card_selection( mut selection_events: EventReader<CardSelectedEvent>, card_query: Query<&Card>, ) { for event in selection_events.read() { if event.selected && card_query.get(event.entity).is_ok() { // Card was selected, perform action println!("Card selected: {:?}", event.entity); } } } }
Performance Considerations
The selection system is optimized to handle large numbers of selectable entities:
- Spatial partitioning for efficient hover detection
- Event-based notifications to avoid polling
- Selective visual updates for better rendering performance
Future Enhancements
Planned improvements to the selection system include:
- Customizable selection visuals through themes
- Advanced multi-select patterns (double-click to select all similar cards)
- Persistent selection groups for complex game actions
- AI assistance for suggested selections
For more details on implementing custom selection behavior, see the card integration documentation.
Drag and Drop System
The drag and drop system allows players to move game objects like cards between different zones using intuitive mouse-based interactions.
Components
Draggable
The Draggable
component marks entities that can be dragged and contains:
#![allow(unused)] fn main() { pub struct Draggable { /// Whether the entity is currently being dragged pub dragging: bool, /// Offset from the mouse cursor to the entity's origin pub drag_offset: Vec2, /// Z-index for rendering order pub z_index: f32, } }
Core System
The drag system is implemented in drag_system
which handles:
- Detecting when a draggable entity is clicked
- Starting the drag operation with the appropriate offset
- Moving the entity with the mouse cursor
- Handling release events to end the drag operation
Z-index Management
The system automatically manages z-indices to ensure that dragged objects appear on top of other game elements. When multiple draggable entities overlap, the one with the highest z-index is selected for dragging.
Integration Points
Card Integration
The drag and drop system integrates with the card visualization system to allow players to:
- Move cards between hand, battlefield, and other zones
- Organize cards within a zone
- Reveal cards or interact with them in specific ways
Targeting Integration
Drag and drop operations are used in conjunction with the targeting system to select targets for spells and abilities. This creates a fluid interaction where players can drag a card to play it and then naturally continue to select targets.
Plugin Setup
To enable drag and drop functionality, the DragPlugin
must be added to your Bevy app:
#![allow(unused)] fn main() { app.add_plugins(DragPlugin); }
Implementation Example
Below is a simplified example of how to make an entity draggable:
#![allow(unused)] fn main() { // Spawn a card entity with a draggable component commands.spawn(( card_bundle, Draggable { dragging: false, drag_offset: Vec2::ZERO, z_index: 1.0, }, )); }
Customization
The drag and drop system can be customized by:
- Adding different visual feedback during dragging
- Implementing custom drop target detection
- Creating drag constraints for certain game zones
For more information on implementing custom drag behaviors, see the game state integration documentation.
Targeting System
The targeting system allows players to select targets for spells, abilities, and other effects.
Core Concepts
The targeting system consists of:
- Target Sources: Cards or abilities that require targets
- Target Validators: Rules that determine valid targets
- Target Selectors: UI elements that allow players to select targets
- Visual Feedback: Effects that show targeting in progress
Targeting Flow
- Player initiates targeting (by playing a card or activating an ability)
- System highlights valid targets based on game rules
- Player selects target(s)
- System validates the selection
- Action completes with chosen targets
Component Structure
#![allow(unused)] fn main() { pub struct TargetSource { pub targeting_active: bool, pub required_targets: usize, pub current_targets: Vec<Entity>, pub target_rules: TargetRules, } pub struct Targetable { pub can_be_targeted: bool, pub highlight_color: Color, } }
Visual Effects
The targeting system provides several visual cues:
- Highlighting valid targets
- Animated arcs or arrows from source to target
- Pulsing effects on potential targets
- Confirmation animations when targets are selected
Integration
The targeting system integrates with:
- Card Selection: Selected cards can be used as targets
- Drag and Drop: Dragging can initiate targeting
- Game Rules: Rules determine valid targets
Implementation Example
#![allow(unused)] fn main() { // Create a spell that requires targeting commands.spawn(( card_bundle, TargetSource { targeting_active: false, required_targets: 1, current_targets: Vec::new(), target_rules: TargetRules::CreaturesOnly, }, )); // Make a creature targetable commands.spawn(( creature_bundle, Targetable { can_be_targeted: true, highlight_color: Color::rgb(0.9, 0.3, 0.3), }, )); }
For more details on targeting rules, see the Magic rules on targeting.
Virtual Table Layout
This document details the virtual table system used in Rummage for multiplayer Commander games. The table design accommodates anywhere from 2 to 6+ players while maintaining an intuitive and functional interface.
Table of Contents
Overview
The virtual table serves as the central organizing element of the game UI. It:
- Positions players around a central battlefield
- Scales dynamically based on player count
- Provides appropriate space for individual and shared game zones
- Maintains visual clarity regardless of player count
The table's design simulates sitting around a physical table but takes advantage of the digital medium to maximize usability and information presentation.
Adaptive Player Positioning
Player Count Configurations
The table dynamically adjusts to accommodate different player counts:
Two Player Configuration
┌─────────────────────────────────────────────────────┐
│ │
│ OPPONENT PLAYMAT │
│ │
├─────────────────────────────────────────────────────┤
│ │
│ SHARED AREA │
│ (Command Zone, Stack, etc.) │
│ │
├─────────────────────────────────────────────────────┤
│ │
│ PLAYER PLAYMAT │
│ │
└─────────────────────────────────────────────────────┘
Three Player Configuration
┌─────────────────────────────────────────────────────┐
│ │
│ OPPONENT 1 PLAYMAT OPPONENT 2 PLAYMAT │
│ │
├─────────────────────────────────────────────────────┤
│ │
│ SHARED AREA │
│ (Command Zone, Stack, etc.) │
│ │
├─────────────────────────────────────────────────────┤
│ │
│ PLAYER PLAYMAT │
│ │
└─────────────────────────────────────────────────────┘
Four Player Configuration (Default)
┌─────────────────────────────────────────────────────┐
│ │
│ OPPONENT 2 PLAYMAT │
│ │
├────────────────┬────────────────┬──────────────────┤
│ │ │ │
│ OPPONENT 1 │ SHARED AREA │ OPPONENT 3 │
│ PLAYMAT │ (Command Zone, │ PLAYMAT │
│ │ Stack, etc.) │ │
│ │ │ │
├────────────────┴────────────────┴──────────────────┤
│ │
│ PLAYER PLAYMAT │
│ │
└─────────────────────────────────────────────────────┘
Five+ Player Configuration
For five or more players, the table uses a scrollable/zoomable view that arranges players in a circular pattern, with the active player and their adjacent opponents given visual priority.
Adaptive Positioning Algorithm
The table employs an adaptive algorithm that:
- Places the local player at the bottom
- Positions opponents based on turn order
- Allocates screen space proportionally based on player count
- Adjusts element scaling to maintain readability
- Prioritizes visibility for the active player and important game elements
Shared Zones
The virtual table includes shared game zones accessible to all players:
Command Zone
Central area for commander cards, emblems, and other command-zone specific elements.
The Stack
Visual representation of spells and abilities currently on the stack, showing order and targeting information.
Exile Zone
Shared exile area for face-up exiled cards that should be visible to all players.
Game Information Display
Shows game state information such as turn number, active phase, priority holder, etc.
Implementation Details
The table is implemented using Bevy's UI system with hierarchical nodes:
#![allow(unused)] fn main() { fn setup_virtual_table( mut commands: Commands, game_state: Res<GameState>, asset_server: Res<AssetServer>, ) { // Main table container commands .spawn(( Node { width: Val::Percent(100.0), height: Val::Percent(100.0), flex_direction: FlexDirection::Column, ..default() }, VirtualTable, AppLayer::GameUI.layer(), )) .with_children(|table| { // Layout depends on player count match game_state.player_count { 2 => setup_two_player_layout(table, &asset_server), 3 => setup_three_player_layout(table, &asset_server), 4 => setup_four_player_layout(table, &asset_server), _ => setup_many_player_layout(table, &asset_server, game_state.player_count), } }); } }
Player Position Calculation
Each player's position is calculated based on the total player count and their index:
#![allow(unused)] fn main() { /// Calculate angle and position for player zones based on player count fn calculate_player_position(player_index: usize, player_count: usize) -> Vec2 { let angle = (player_index as f32 / player_count as f32) * std::f32::consts::TAU; let distance = 400.0; // Distance from center Vec2::new( distance * angle.cos(), distance * angle.sin(), ) } }
Dynamic Resizing
The table responds to window size changes, maintaining appropriate proportions:
#![allow(unused)] fn main() { fn update_table_responsive_layout( mut query: Query<&mut Node, With<VirtualTable>>, window: Query<&Window>, ) { let window = window.single(); let aspect_ratio = window.width() / window.height(); for mut node in query.iter_mut() { // Adjust layout based on aspect ratio if aspect_ratio > 1.5 { // Landscape orientation node.flex_direction = FlexDirection::Column; } else { // Portrait orientation node.flex_direction = FlexDirection::Row; } } } }
Testing
The virtual table's adaptive layout should be thoroughly tested across different scenarios:
Unit Tests
- Test player position calculation for different player counts
- Verify layout component hierarchy for each player configuration
- Test responsive resizing behavior
Integration Tests
- Verify all player zones are visible and accessible
- Test navigation between player areas
- Ensure shared zones maintain visibility for all players
Visual Tests
- Confirm layout appearance across different screen sizes
- Verify scaling behavior for UI elements
- Test readability of cards and game information
Performance Tests
- Measure rendering performance with maximum player count
- Test scrolling/zooming smoothness
- Verify UI responsiveness during layout transitions
Battlefield Layout
Card Stacking
Player Playmat
This document details the player playmat system in Rummage, representing each player's personal play area with all the zones required for Magic: The Gathering Commander gameplay.
Table of Contents
- Overview
- Playmat Zones
- Zone Interactions
- Adaptive Sizing
- Visual Customization
- Implementation Details
- Testing
Overview
Each player in a Rummage game has their own playmat that:
- Contains all required Magic: The Gathering game zones
- Provides clear visual separation between zones
- Ensures cards and game elements are easily accessible
- Adapts based on available screen space
- Integrates with the overall virtual table layout
Playmat Zones
The playmat includes the following zones, as dictated by Magic: The Gathering rules:
Battlefield
The primary play area where permanents (creatures, artifacts, enchantments, planeswalkers, lands) are placed when cast.
┌─────────────────────────────────────────────────────┐
│ │
│ │
│ │
│ BATTLEFIELD │
│ │
│ │
│ │
└─────────────────────────────────────────────────────┘
- Supports tapped/untapped card states
- Organizes cards by type (creatures, lands, etc.)
- Allows custom grouping of permanents
- Supports tokens and copy effects
Hand
The player's hand of cards, visible only to the player (and spectators with appropriate permissions).
┌─────────────────────────────────────────────────────┐
│ │
│ HAND │
│ │
└─────────────────────────────────────────────────────┘
- Displays cards in a fan-out layout
- Provides detail view on hover/select
- Shows card count for opponents
- Supports sorting and filtering
Library (Deck)
The player's library, from which they draw cards.
┌───────────┐
│ │
│ LIBRARY │
│ │
└───────────┘
- Shows deck position and card count
- Animates card draw and shuffle
- Displays deck state (e.g., being searched)
- Supports placing cards on top/bottom of library
Graveyard
Discard pile for cards that have been destroyed, discarded, or sacrificed.
┌───────────┐
│ │
│ GRAVEYARD │
│ │
└───────────┘
- Shows most recent cards on top
- Supports browsing full contents
- Displays count of cards in graveyard
- Animates cards entering the graveyard
Exile
Area for cards removed from the game.
┌───────────┐
│ │
│ EXILE │
│ │
└───────────┘
- Distinguishes between face-up and face-down exiled cards
- Groups cards by the effect that exiled them
- Shows duration for temporary exile effects
Command Zone
Special zone for commander cards and emblems.
┌───────────┐
│ │
│ COMMAND │
│ ZONE │
│ │
└───────────┘
- Prominently displays the player's commander(s)
- Shows commander tax amount
- Tracks commander damage given/received
- Displays emblems and other command zone objects
Complete Playmat Layout
The full playmat integrates these zones into a cohesive layout:
┌─────────────────────────────────────────────────────┐
│ HAND │
├───────────┬───────────────────────────┬─────────────┤
│ │ │ │
│ LIBRARY │ │ GRAVEYARD │
│ │ │ │
├───────────┤ ├─────────────┤
│ │ │ │
│ │ BATTLEFIELD │ │
│ EXILE │ │ COMMAND │
│ │ │ ZONE │
│ │ │ │
└───────────┴───────────────────────────┴─────────────┘
Zone Interactions
The playmat facilitates several interactions between zones:
Card Movement
Cards can move between zones through:
- Drag and drop gestures
- Context menu actions
- Keyboard shortcuts
- Game action triggers
Each movement includes appropriate animations to indicate the source and destination zones.
Zone Focus
A zone can be focused to display more detailed information:
- Expanded graveyard view
- Detailed hand card inspection
- Library search views
Focus interactions maintain context by showing the relationship to other zones.
Adaptive Sizing
The playmat adapts to different screen sizes and player counts:
Size Adaptations
- Full View: When it's the player's turn or they have priority
- Compact View: During opponent turns
- Minimal View: When the player is not directly involved in the current game action
Active Zone Emphasis
The current phase of the game influences zone emphasis:
- During main phases, the battlefield and hand are emphasized
- During combat, the battlefield receives more space
- When searching library, the library zone expands
Visual Customization
Players can customize their playmat's appearance:
- Custom playmat backgrounds
- Zone color themes
- Card arrangement preferences
- Animation settings
Implementation Details
The playmat is implemented using Bevy's UI system:
#![allow(unused)] fn main() { fn setup_player_playmat( mut commands: Commands, player: &Player, asset_server: &Res<AssetServer>, ) { // Main playmat container commands .spawn(( Node { width: Val::Percent(100.0), height: Val::Percent(100.0), flex_direction: FlexDirection::Column, ..default() }, PlayerPlaymat { player_id: player.id }, AppLayer::GameUI.layer(), )) .with_children(|playmat| { // Hand zone setup_hand_zone(playmat, player, asset_server); // Middle section containing battlefield and side zones playmat .spawn(Node { width: Val::Percent(100.0), height: Val::Percent(80.0), flex_direction: FlexDirection::Row, ..default() }) .with_children(|middle| { // Left side zones setup_left_zones(middle, player, asset_server); // Battlefield setup_battlefield(middle, player, asset_server); // Right side zones setup_right_zones(middle, player, asset_server); }); }); } }
Zone Components
Each zone is implemented as a component that can be queried and manipulated:
#![allow(unused)] fn main() { /// Component for the player's hand zone #[derive(Component)] struct HandZone { player_id: PlayerId, expanded: bool, } /// Component for the battlefield zone #[derive(Component)] struct BattlefieldZone { player_id: PlayerId, organization: BattlefieldOrganization, } // Other zone components follow a similar pattern }
Responsive Layout Systems
Systems manage the responsive behavior of the playmat:
#![allow(unused)] fn main() { fn update_playmat_layout( mut playmat_query: Query<(&mut Node, &PlayerPlaymat)>, player_turn_query: Query<&ActiveTurn>, window: Query<&Window>, ) { let window = window.single(); let active_turn = player_turn_query.single(); let is_landscape = window.width() > window.height(); for (mut node, playmat) in playmat_query.iter_mut() { let is_active_player = active_turn.player_id == playmat.player_id; // Adjust layout based on active player and orientation if is_active_player { node.height = Val::Percent(if is_landscape { 40.0 } else { 50.0 }); } else { node.height = Val::Percent(if is_landscape { 20.0 } else { 25.0 }); } } } }
Testing
The playmat should be thoroughly tested to ensure proper functionality and visual appearance:
Unit Tests
- Test correct initialization of all zones
- Verify component hierarchy
- Ensure proper event handling for zone interactions
Integration Tests
- Test card movement between zones
- Verify zone focus behavior
- Test responsive layout changes
Visual Tests
- Verify appearance across different screen sizes
- Test with different card counts in each zone
- Ensure readability of card information
Gameplay Tests
- Test common gameplay patterns involving multiple zones
- Verify commander damage tracking
- Test special zone interactions like flashback from graveyard
Zone Design
Visual Themes
In-Game Chat System
This document details the in-game chat system in Rummage, providing communication capabilities for players during Commander format games.
Table of Contents
- Overview
- Chat System Components
- Integration With Game UI
- Accessibility Features
- Implementation Details
- Testing
- Related Documentation
Overview
The in-game chat system provides multiple communication channels for players during gameplay:
- Text Chat: Traditional text-based communication
- Voice Chat: Real-time audio communication
- Game Event Messages: Automated messages about game events
- Emotes and Reactions: Pre-defined expressions and reactions
The chat system is designed to be non-intrusive while remaining easily accessible during gameplay, enhancing the social experience of Commander format games.
Chat System Components
The chat system consists of several integrated components:
Text Chat
The text chat component allows players to type and send messages to others in the game. Features include:
- Chat Channels: Public, private, and team channels
- Message Formatting: Support for basic text formatting
- Command System: Chat commands for game actions
- Message History: Accessible chat history with search capabilities
Detailed Text Chat Documentation
Voice Chat
The voice chat component enables real-time audio communication between players:
- Push-to-Talk: Configurable key binding for activating microphone
- Voice Activity Detection: Optional automatic activation based on speech
- Player Indicators: Visual cues showing who is speaking
- Individual Volume Controls: Adjust volume for specific players
Detailed Voice Chat Documentation
Game Event Messages
Automated messages about game actions and events:
- Stackable Notifications: Collapsible event messages
- Filtering Options: Configure which events generate messages
- Verbosity Settings: Adjust level of detail in event messages
- Highlighting: Color coding for important events
Emotes and Reactions
Quick non-verbal communication options:
- Contextual Emotes: Reactions appropriate to game context
- Emote Wheel: Quick access to common emotes
- Custom Emotes: Limited customization options
- Cooldown System: Prevents emote spam
Integration With Game UI
The chat system integrates seamlessly with the game UI:
Chat Window Modes
The chat window can appear in multiple states:
- Expanded View: Full chat interface with history and channels
- Minimized View: Condensed view showing recent messages
- Hidden: Completely hidden with notification indicators for new messages
- Pop-out: Detachable window for multi-monitor setups
Positioning
The chat interface can be positioned in different areas:
- Default: Bottom-left corner of the screen
- Customizable: User can reposition within constraints
- Contextual: Automatic repositioning based on game state
- Size Adjustable: Resizable chat window
Visual Integration
Visual elements that tie the chat system to the game:
- Player Color Coding: Message colors match player identities
- Thematic Styling: Chat UI follows game's visual language
- Transition Effects: Smooth animations for state changes
- Focus Management: Proper keyboard focus handling
Accessibility Features
The chat system includes several accessibility features:
- Text-to-Speech: Optional reading of incoming messages
- Speech-to-Text: Voice transcription for voice chat
- High Contrast Mode: Improved readability options
- Customizable Text Size: Adjustable font sizes
- Keyboard Navigation: Complete keyboard control
- Alternative Communication: Pre-defined phrases for quick communication
- Message Timing: Configurable message display duration
Implementation Details
The chat system is implemented using Bevy's ECS architecture:
Components
#![allow(unused)] fn main() { /// Component for the chat window #[derive(Component)] struct ChatWindow { mode: ChatMode, active_channel: ChatChannel, position: ChatPosition, is_focused: bool, } /// Component for input field #[derive(Component)] struct ChatInput { text: String, cursor_position: usize, selection_range: Option<(usize, usize)>, } /// Component for chat message display #[derive(Component)] struct ChatMessageDisplay { messages: Vec<ChatMessage>, scroll_position: f32, filter_settings: ChatFilterSettings, } /// Component for voice activity #[derive(Component)] struct VoiceActivity { is_active: bool, volume_level: f32, player_id: PlayerId, } /// Resource for chat settings #[derive(Resource)] struct ChatSettings { text_chat_enabled: bool, voice_chat_enabled: bool, message_history_size: usize, notification_settings: NotificationSettings, accessibility_settings: ChatAccessibilitySettings, } }
Systems
#![allow(unused)] fn main() { /// System to handle incoming chat messages fn handle_chat_messages( mut messages_query: Query<&mut ChatMessageDisplay>, chat_events: EventReader<ChatMessageEvent>, settings: Res<ChatSettings>, ) { // Implementation } /// System to handle voice chat fn process_voice_chat( mut voice_activity_query: Query<(&mut VoiceActivity, &PlayerId)>, audio_input: Res<AudioInputBuffer>, settings: Res<ChatSettings>, ) { // Implementation } /// System to update chat UI fn update_chat_ui( mut chat_window_query: Query<(&mut ChatWindow, &mut Node, &Children)>, keyboard_input: Res<ButtonInput<KeyCode>>, time: Res<Time>, ) { // Implementation } }
Chat Window Setup
#![allow(unused)] fn main() { /// Setup the chat window fn setup_chat_window( mut commands: Commands, asset_server: Res<AssetServer>, chat_settings: Res<ChatSettings>, ) { // Main chat container commands .spawn(( ChatWindow { mode: ChatMode::Minimized, active_channel: ChatChannel::Global, position: ChatPosition::BottomLeft, is_focused: false, }, Node { width: Val::Px(400.0), height: Val::Px(300.0), position_type: PositionType::Absolute, bottom: Val::Px(10.0), left: Val::Px(10.0), flex_direction: FlexDirection::Column, ..default() }, BackgroundColor(Color::rgba(0.1, 0.1, 0.1, 0.8)), BorderColor(Color::rgb(0.3, 0.3, 0.3)), Outline::new(Val::Px(1.0)), AppLayer::GameUI.layer(), Visibility::Visible, )) .with_children(|parent| { // Chat header setup_chat_header(parent, &asset_server); // Message display area setup_message_display(parent, &asset_server); // Chat input area setup_chat_input(parent, &asset_server); // Voice chat indicators if chat_settings.voice_chat_enabled { setup_voice_indicators(parent, &asset_server); } }); } }
Testing
The chat system requires thorough testing to ensure reliability and performance:
Unit Tests
- Test message processing
- Verify channel functionality
- Test input handling
- Validate filtering systems
Integration Tests
- Test chat integration with game events
- Verify voice chat synchronization
- Test accessibility features
- Validate UI responsiveness
Performance Tests
- Test with high message volume
- Measure voice chat latency
- Verify memory usage with large chat history
- Test network bandwidth usage
Usability Tests
- Validate readability
- Test keyboard navigation
- Verify mobile touch interactions
- Test with screen readers
Related Documentation
Message Display
Emote System
Chat System API for Plugins
This document provides detailed information about the chat system API that plugin developers can use to integrate with Rummage's in-game communication system.
Table of Contents
- Overview
- Core API Components
- Text Chat Integration
- Voice Chat Integration
- Events and Messages
- UI Customization
- Examples
- Best Practices
Overview
The chat API allows plugin developers to integrate with the existing chat system, enabling custom chat commands, specialized message formats, voice chat extensions, and UI customizations. This API is designed to be flexible while maintaining compatibility with the core chat functionality.
Key API features:
- Send and receive chat messages through events
- Register custom chat commands
- Add custom UI elements to the chat interface
- Hook into voice activity detection
- Add specialized message types
- Access chat history
Core API Components
Chat Plugin
The main entry point for plugins integrating with the chat system:
#![allow(unused)] fn main() { /// Plugin for chat system integration pub struct ChatPlugin; impl Plugin for ChatPlugin { fn build(&self, app: &mut App) { app.add_event::<ChatMessageEvent>() .add_event::<VoicePacketEvent>() .add_event::<ChatCommandEvent>() .init_resource::<ChatSettings>() .init_resource::<VoiceChatConfig>() .add_systems(Startup, setup_chat_system) .add_systems(Update, ( process_chat_messages, handle_chat_commands, update_chat_ui, process_voice_chat, )); } } }
Public Resources
Resources available for plugins to access and modify:
#![allow(unused)] fn main() { /// Settings for the chat system #[derive(Resource, Clone)] pub struct ChatSettings { pub text_chat_enabled: bool, pub voice_chat_enabled: bool, pub message_history_size: usize, pub notification_settings: NotificationSettings, pub accessibility_settings: ChatAccessibilitySettings, pub command_prefix: String, } /// Voice chat configuration #[derive(Resource, Clone)] pub struct VoiceChatConfig { pub enabled: bool, pub input_device_id: Option<String>, pub output_device_id: Option<String>, pub input_volume: f32, pub output_volume: f32, pub activation_mode: VoiceActivationMode, pub activation_threshold: f32, pub push_to_talk_key: KeyCode, pub noise_suppression_level: NoiseSuppressionLevel, pub echo_cancellation: bool, pub auto_gain_control: bool, } /// Chat message history resource #[derive(Resource)] pub struct ChatHistory { pub messages: Vec<ChatMessage>, pub channels: HashMap<ChatChannel, Vec<ChatMessage>>, pub last_read_indices: HashMap<ChatChannel, usize>, } }
Public Components
Components that plugins can query and modify:
#![allow(unused)] fn main() { /// Chat window component #[derive(Component)] pub struct ChatWindow { pub mode: ChatMode, pub active_channel: ChatChannel, pub position: ChatPosition, pub is_focused: bool, } /// Chat message display component #[derive(Component)] pub struct ChatMessageDisplay { pub messages: Vec<ChatMessage>, pub scroll_position: f32, pub filter_settings: ChatFilterSettings, } /// Voice activity component #[derive(Component)] pub struct VoiceActivity { pub is_active: bool, pub volume_level: f32, pub player_id: PlayerId, } }
Public Events
Events that plugins can send and receive:
#![allow(unused)] fn main() { /// Event for chat messages #[derive(Event, Clone)] pub struct ChatMessageEvent { pub message: ChatMessage, pub recipients: MessageRecipients, } /// Event for voice packets #[derive(Event)] pub struct VoicePacketEvent { pub player_id: PlayerId, pub audio_data: Vec<u8>, pub sequence_number: u32, pub timestamp: f64, pub channel: VoiceChannel, } /// Event for chat commands #[derive(Event)] pub struct ChatCommandEvent { pub command: String, pub args: Vec<String>, pub sender_id: PlayerId, pub channel: ChatChannel, } }
Text Chat Integration
Sending Messages
Plugins can send messages to the chat system:
#![allow(unused)] fn main() { /// Send a chat message from a plugin pub fn send_chat_message( message_text: &str, sender_name: &str, message_type: MessageType, channel: ChatChannel, message_events: &mut EventWriter<ChatMessageEvent>, ) { let message = ChatMessage { sender_id: None, // None indicates system/plugin sender sender_name: sender_name.to_string(), content: message_text.to_string(), timestamp: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs_f64(), channel, message_type, formatting: None, }; message_events.send(ChatMessageEvent { message, recipients: MessageRecipients::All, }); } }
Reading Messages
Plugins can read messages from the chat system:
#![allow(unused)] fn main() { /// System to read chat messages in a plugin pub fn read_chat_messages( mut message_events: EventReader<ChatMessageEvent>, mut plugin_state: ResMut<MyPluginState>, ) { for event in message_events.read() { // Process incoming messages plugin_state.process_message(&event.message); // Log or analyze messages if event.message.content.contains(&plugin_state.keyword) { // Do something with this message } } } }
Registering Custom Commands
Plugins can register custom chat commands:
#![allow(unused)] fn main() { /// Register a custom chat command pub fn register_chat_command( command: &str, description: &str, mut commands: ResMut<ChatCommandRegistry>, ) { commands.register( command.to_string(), ChatCommandInfo { description: description.to_string(), permission_level: PermissionLevel::User, handler: ChatCommandHandler::Plugin("my_plugin".to_string()), }, ); } /// System to handle custom chat commands pub fn handle_custom_commands( mut command_events: EventReader<ChatCommandEvent>, mut message_events: EventWriter<ChatMessageEvent>, // Plugin-specific resources and access ) { for event in command_events.read() { if event.command == "my_custom_command" { // Handle the custom command // ... // Send response send_chat_message( "Command processed successfully!", "MyPlugin", MessageType::System, event.channel, &mut message_events, ); } } } }
Voice Chat Integration
Voice Activity Detection
Plugins can hook into voice activity detection:
#![allow(unused)] fn main() { /// System to monitor voice activity pub fn monitor_voice_activity( voice_activity: Query<&VoiceActivity>, mut plugin_state: ResMut<MyPluginVoiceState>, ) { for activity in &voice_activity { if activity.is_active { // Player is speaking plugin_state.player_speaking(activity.player_id, activity.volume_level); } else if plugin_state.was_speaking(activity.player_id) { // Player stopped speaking plugin_state.player_stopped_speaking(activity.player_id); } } } }
Voice Processing
Plugins can process voice audio:
#![allow(unused)] fn main() { /// System to process voice packets pub fn process_voice_packets( mut voice_events: EventReader<VoicePacketEvent>, mut processed_voice_events: EventWriter<VoicePacketEvent>, plugin_voice_processor: Res<MyVoiceProcessor>, ) { for event in voice_events.read() { // Get the audio data let audio_data = &event.audio_data; // Process the audio (e.g., apply effects, filter, etc.) let processed_data = plugin_voice_processor.process_audio(audio_data); // Create a new event with processed data processed_voice_events.send(VoicePacketEvent { player_id: event.player_id, audio_data: processed_data, sequence_number: event.sequence_number, timestamp: event.timestamp, channel: event.channel, }); } } }
Events and Messages
Custom Message Types
Plugins can define custom message types:
#![allow(unused)] fn main() { /// Define custom message types #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CustomMessageType { Achievement, Tip, Warning, Debug, } /// Convert custom message type to standard type impl From<CustomMessageType> for MessageType { fn from(custom_type: CustomMessageType) -> Self { match custom_type { CustomMessageType::Achievement => MessageType::System, CustomMessageType::Tip => MessageType::System, CustomMessageType::Warning => MessageType::Error, CustomMessageType::Debug => MessageType::System, } } } /// Send a custom message pub fn send_custom_message( text: &str, custom_type: CustomMessageType, mut message_events: EventWriter<ChatMessageEvent>, ) { let message_type: MessageType = custom_type.into(); // Create message with custom formatting based on type let formatting = match custom_type { CustomMessageType::Achievement => Some(MessageFormatting { color: Some(Color::GOLD), icon: Some("achievement".to_string()), ..Default::default() }), CustomMessageType::Tip => Some(MessageFormatting { color: Some(Color::BLUE), icon: Some("tip".to_string()), ..Default::default() }), // ...other types _ => None, }; let message = ChatMessage { sender_id: None, sender_name: match custom_type { CustomMessageType::Achievement => "Achievement Unlocked".to_string(), CustomMessageType::Tip => "Tip".to_string(), CustomMessageType::Warning => "Warning".to_string(), CustomMessageType::Debug => "Debug".to_string(), }, content: text.to_string(), timestamp: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs_f64(), channel: ChatChannel::System, message_type, formatting, }; message_events.send(ChatMessageEvent { message, recipients: MessageRecipients::All, }); } }
Event Interception
Plugins can intercept and modify chat events:
#![allow(unused)] fn main() { /// System to intercept chat messages pub fn intercept_chat_messages( mut reader: EventReader<ChatMessageEvent>, mut writer: EventWriter<ChatMessageEvent>, plugin_config: Res<MyPluginConfig>, ) { for event in reader.read() { // Make a mutable copy of the event let mut modified_event = event.clone(); // Apply modifications based on plugin rules if plugin_config.should_filter_profanity { modified_event.message.content = filter_profanity(&modified_event.message.content); } if plugin_config.should_translate && event.message.content.starts_with("!translate") { // Translate message to selected language modified_event.message.content = translate_message( &modified_event.message.content, plugin_config.target_language.clone(), ); } // Forward the modified event writer.send(modified_event); } } }
UI Customization
Adding UI Elements
Plugins can add custom UI elements to the chat interface:
#![allow(unused)] fn main() { /// System to add custom UI elements to chat pub fn setup_custom_chat_ui( mut commands: Commands, chat_window_query: Query<Entity, With<ChatWindow>>, asset_server: Res<AssetServer>, ) { if let Ok(chat_entity) = chat_window_query.get_single() { // Add a custom button to the chat window commands.entity(chat_entity).with_children(|parent| { parent.spawn(( CustomChatButton::TranslateToggle, ButtonBundle { style: Style { width: Val::Px(24.0), height: Val::Px(24.0), // ...other style properties ..default() }, background_color: BackgroundColor(Color::rgba(0.3, 0.5, 0.8, 0.6)), ..default() }, )) .with_children(|button| { button.spawn(Text2d { text: "🌐".into(), font: asset_server.load("fonts/NotoEmoji-Regular.ttf"), font_size: 16.0, // ...other text properties }); }); }); } } /// System to handle custom UI interactions pub fn handle_custom_chat_ui( interaction_query: Query< (&Interaction, &CustomChatButton), (Changed<Interaction>, With<Button>), >, mut plugin_config: ResMut<MyPluginConfig>, ) { for (interaction, button) in &interaction_query { if *interaction == Interaction::Pressed { match button { CustomChatButton::TranslateToggle => { // Toggle translation feature plugin_config.should_translate = !plugin_config.should_translate; }, // Handle other custom buttons // ... } } } } }
Styling Messages
Plugins can define custom message styles:
#![allow(unused)] fn main() { /// Define custom message style pub struct CustomMessageStyle { pub background_color: Color, pub text_color: Color, pub border_color: Option<Color>, pub icon: Option<String>, pub font_style: FontStyle, } /// Register custom message styles pub fn register_custom_styles( mut style_registry: ResMut<ChatStyleRegistry>, ) { // Register achievement style style_registry.register( "achievement", CustomMessageStyle { background_color: Color::rgba(0.1, 0.1, 0.1, 0.9), text_color: Color::rgb(1.0, 0.84, 0.0), // Gold border_color: Some(Color::rgb(0.8, 0.7, 0.0)), icon: Some("trophy".to_string()), font_style: FontStyle::Bold, }, ); // Register other styles // ... } }
Examples
Basic Chat Command Plugin
A simple plugin that adds a dice rolling command:
#![allow(unused)] fn main() { /// Dice roller plugin pub struct DiceRollerPlugin; impl Plugin for DiceRollerPlugin { fn build(&self, app: &mut App) { app.init_resource::<DiceRollerConfig>() .add_systems(Startup, register_dice_commands) .add_systems(Update, handle_dice_commands); } } /// Configuration for dice roller #[derive(Resource, Default)] struct DiceRollerConfig { max_dice: u32, max_sides: u32, } /// Register dice rolling commands fn register_dice_commands( mut command_registry: ResMut<ChatCommandRegistry>, ) { command_registry.register( "roll".to_string(), ChatCommandInfo { description: "Roll dice. Usage: /roll XdY".to_string(), permission_level: PermissionLevel::User, handler: ChatCommandHandler::Plugin("dice_roller".to_string()), }, ); } /// Handle dice rolling commands fn handle_dice_commands( mut command_events: EventReader<ChatCommandEvent>, mut message_events: EventWriter<ChatMessageEvent>, config: Res<DiceRollerConfig>, ) { for event in command_events.read() { if event.command == "roll" { // Parse arguments (e.g., "2d6") if let Some(arg) = event.args.first() { if let Some((count_str, sides_str)) = arg.split_once('d') { // Parse dice count and sides if let (Ok(count), Ok(sides)) = (count_str.parse::<u32>(), sides_str.parse::<u32>()) { // Validate against config limits if count > 0 && count <= config.max_dice && sides > 0 && sides <= config.max_sides { // Roll the dice let mut rng = rand::thread_rng(); let rolls: Vec<u32> = (0..count) .map(|_| rng.gen_range(1..=sides)) .collect(); let total: u32 = rolls.iter().sum(); // Format the result let rolls_str = rolls .iter() .map(|r| r.to_string()) .collect::<Vec<_>>() .join(", "); let result_message = format!( "{} rolled {}d{}: [{}] = {}", event.sender_id, count, sides, rolls_str, total ); // Send the message send_chat_message( &result_message, "Dice Roller", MessageType::System, event.channel, &mut message_events, ); } else { // Invalid dice parameters send_chat_message( &format!( "Invalid dice parameters. Maximum {} dice with {} sides.", config.max_dice, config.max_sides ), "Dice Roller", MessageType::Error, event.channel, &mut message_events, ); } } } } } } } }
Voice Effect Plugin
A plugin that adds voice effects:
#![allow(unused)] fn main() { /// Voice effect plugin pub struct VoiceEffectPlugin; impl Plugin for VoiceEffectPlugin { fn build(&self, app: &mut App) { app.init_resource::<VoiceEffectSettings>() .add_systems(Startup, setup_voice_effect_ui) .add_systems(Update, ( process_voice_effects, update_effect_settings, )); } } /// Voice effect settings #[derive(Resource)] struct VoiceEffectSettings { active_effect: Option<VoiceEffectType>, pitch_shift: f32, reverb_amount: f32, distortion: f32, } /// Voice effect types enum VoiceEffectType { None, HighPitch, LowPitch, Robot, Echo, Custom, } /// System to process voice with effects fn process_voice_effects( mut voice_events: EventReader<VoicePacketEvent>, mut processed_voice_events: EventWriter<VoicePacketEvent>, settings: Res<VoiceEffectSettings>, local_player: Res<LocalPlayer>, ) { // Only process local player's voice for event in voice_events.read() { if event.player_id != local_player.id { // Forward other players' packets unchanged processed_voice_events.send(event.clone()); continue; } // Skip processing if no effect is active if settings.active_effect == Some(VoiceEffectType::None) || settings.active_effect.is_none() { processed_voice_events.send(event.clone()); continue; } // Decompress audio data let decompressed = decompress_audio_data(&event.audio_data); // Apply selected effect let processed_audio = match settings.active_effect { Some(VoiceEffectType::HighPitch) => apply_pitch_shift(&decompressed, 1.5), Some(VoiceEffectType::LowPitch) => apply_pitch_shift(&decompressed, 0.7), Some(VoiceEffectType::Robot) => apply_robot_effect(&decompressed), Some(VoiceEffectType::Echo) => apply_echo_effect(&decompressed, 0.5, 0.3), Some(VoiceEffectType::Custom) => apply_custom_effect( &decompressed, settings.pitch_shift, settings.reverb_amount, settings.distortion ), _ => decompressed, }; // Compress processed audio let compressed = compress_audio_data(&processed_audio); // Send modified packet processed_voice_events.send(VoicePacketEvent { player_id: event.player_id, audio_data: compressed, sequence_number: event.sequence_number, timestamp: event.timestamp, channel: event.channel, }); } } }
Best Practices
Performance Considerations
- Minimize processing in chat message handling systems
- Use efficient algorithms for text processing
- Cache results of expensive operations
- For voice processing, limit complexity when multiple players are speaking
- Clean up resources when plugins are disabled
Compatibility
- Follow the chat message formatting conventions
- Don't override default commands without good reason
- Provide fallback behavior when your plugin's features are disabled
- Test interactions with other popular plugins
User Experience
- Make plugin features discoverable through chat help commands
- Provide clear feedback for user actions
- Allow users to disable or customize plugin features
- Don't spam the chat with too many messages
- Respect user privacy settings with voice processing
Error Handling
- Validate all user input before processing
- Provide helpful error messages for invalid commands
- Catch and log exceptions rather than crashing
- Gracefully handle missing resources or components
- Have fallback behavior when network operations fail
Chat System Network Protocol
This document details the network protocol used by Rummage's in-game chat system for transmitting text and voice data between players.
Table of Contents
- Overview
- Network Architecture
- Text Chat Protocol
- Voice Chat Protocol
- Security Considerations
- Bandwidth Optimization
- Implementation Details
- Error Handling
Overview
The chat network protocol is designed to efficiently transmit both text and voice data between players in a Commander game. It prioritizes low latency for real-time communication while maintaining bandwidth efficiency and reliability.
Key design principles:
- Efficiency: Minimize bandwidth usage without sacrificing quality
- Reliability: Ensure delivery of text messages while optimizing voice transmission
- Security: Protect player communication from tampering and eavesdropping
- Scalability: Support multiple players with varying network conditions
- Extensibility: Allow for future protocol extensions and enhancements
Network Architecture
Communication Model
The chat system uses a hybrid networking model:
- Peer-to-Peer Mode: Direct connections between players for lower latency (default for voice)
- Client-Server Mode: Messages routed through a central server (default for text)
- Relay Mode: Fallback when direct connections aren't possible due to NAT/firewalls
Connection Flow
- Initialization: When joining a game, players establish communication channels
- Channel Negotiation: Determine optimal connection type for each player pair
- Session Establishment: Create encrypted sessions for text and voice
- Heartbeat Monitoring: Regular connectivity checks to detect disconnections
- Graceful Termination: Proper session closure when leaving a game
Protocol Layers
The network protocol is organized in layers:
┌─────────────────────────────────────┐
│ Application Layer (Chat UI/Logic) │
├─────────────────────────────────────┤
│ Message Layer (Serialization/Types) │
├─────────────────────────────────────┤
│ Session Layer (Encryption/Auth) │
├─────────────────────────────────────┤
│ Transport Layer (UDP/TCP) │
└─────────────────────────────────────┘
- Transport Layer: Uses TCP for text messages and UDP for voice data
- Session Layer: Handles encryption, authentication, and session management
- Message Layer: Serializes/deserializes messages and handles message types
- Application Layer: Processes messages for display and user interaction
Text Chat Protocol
Message Format
Text messages use a binary format with the following structure:
┌────────┬────────┬───────────┬──────────┬─────────┬────────────┐
│ Header │ Length │ Timestamp │ Metadata │ Payload │ Checksum │
│ (8B) │ (4B) │ (8B) │ (Variable)│ (Variable)│ (4B) │
└────────┴────────┴───────────┴──────────┴─────────┴────────────┘
Header (8 bytes)
┌───────────────┬─────────────┬───────────┬───────────┬───────────┐
│ Protocol Ver. │ Message Type│ Channel ID│ Sender ID │ Flags │
│ (1B) │ (1B) │ (2B) │ (2B) │ (2B) │
└───────────────┴─────────────┴───────────┴───────────┴───────────┘
- Protocol Version: Current protocol version (currently 1)
- Message Type: Type of message (standard, system, command, etc.)
- Channel ID: Channel identifier for routing (global, team, private, etc.)
- Sender ID: Unique ID of the sender
- Flags: Special message flags (e.g., encrypted, acknowledged, etc.)
Length (4 bytes)
Total message length in bytes, including header and checksum.
Timestamp (8 bytes)
Unix timestamp with millisecond precision for message ordering and latency calculation.
Metadata (Variable)
Optional metadata fields depending on message type:
- For standard messages: formatting options, reply references, etc.
- For system messages: event types, severity levels, etc.
- For command messages: command identifiers, argument count, etc.
Payload (Variable)
The actual message content, UTF-8 encoded text. For binary data (e.g., emotes, images), a specialized binary format is used with appropriate type headers.
Checksum (4 bytes)
CRC32 checksum for message integrity verification.
Message Types
The protocol supports various message types:
Type ID | Name | Description |
---|---|---|
0x01 | STANDARD | Regular user message |
0x02 | SYSTEM | System message or notification |
0x03 | COMMAND | Chat command |
0x04 | EMOTE | Emote action |
0x05 | REACTION | Message reaction |
0x06 | STATUS | User status change |
0x07 | READ_RECEIPT | Message read confirmation |
0x08 | TYPING | Typing indicator |
0x09 | BINARY | Binary data (images, etc.) |
0x0A | CONTROL | Protocol control message |
0x0B | VOICE_CONTROL | Voice chat control |
Flow Control
The text chat protocol implements flow control mechanisms:
- Rate Limiting: Maximum messages per second (configurable per channel)
- Message Ordering: Sequence numbers to handle out-of-order delivery
- Message Acknowledgment: Optional ACKs for important messages
- Retry Logic: Automatic retransmission of unacknowledged messages
- Flood Protection: Dynamic rate limiting based on channel activity
Serialization
Message serialization follows these steps:
- Create message object with all required fields
- Serialize message to binary format with appropriate headers
- Apply compression if message exceeds threshold size
- Encrypt message content if required
- Calculate and append checksum
- Transmit over appropriate transport (TCP or WebSocket)
Voice Chat Protocol
Packet Format
Voice data uses a compact binary format optimized for real-time transmission:
┌────────┬────────┬───────────┬──────────┬─────────┬────────────┐
│ Header │ Sequence│ Timestamp │ Channel │ Payload │ Checksum │
│ (4B) │ (2B) │ (4B) │ (1B) │ (Variable)│ (2B) │
└────────┴────────┴───────────┴──────────┴─────────┴────────────┘
Header (4 bytes)
┌───────────────┬─────────────┬───────────┬───────────┐
│ Protocol Ver. │ Coding Type │ Player ID │ Flags │
│ (1B) │ (1B) │ (1B) │ (1B) │
└───────────────┴─────────────┴───────────┴───────────┘
- Protocol Version: Current voice protocol version
- Coding Type: Audio codec and parameters
- Player ID: Unique ID of the speaking player
- Flags: Special flags (e.g., priority speaker, muted, etc.)
Sequence Number (2 bytes)
Sequential packet counter for detecting packet loss and handling jitter.
Timestamp (4 bytes)
Relative timestamp in milliseconds for synchronization and jitter buffer management.
Channel (1 byte)
Voice channel identifier (global, team, private, etc.).
Payload (Variable)
Compressed audio data using the specified codec. Typical payload sizes:
- Opus codec at 20ms frames: ~80-120 bytes per frame
- Range: 10-120 bytes depending on codec and settings
Checksum (2 bytes)
CRC16 checksum for basic packet integrity verification.
Audio Encoding
The voice chat protocol supports multiple audio codecs:
ID | Codec | Bit Rate | Sample Rate | Frame Size |
---|---|---|---|---|
0x01 | Opus | 16-64 kbps | 48 kHz | 20ms |
0x02 | Opus | 8-24 kbps | 24 kHz | 20ms |
0x03 | Opus | 6-12 kbps | 16 kHz | 20ms |
0x04 | Speex | 8-16 kbps | 16 kHz | 20ms |
0x05 | Celt | 16-32 kbps | 32 kHz | 20ms |
Codec selection is automatic based on:
- Available bandwidth
- CPU capabilities
- User quality preferences
- Network conditions
Network Optimization
The voice protocol includes several optimizations:
- Jitter Buffer: Dynamic buffer to handle network timing variations
- Packet Loss Concealment: Interpolation of missing audio frames
- FEC (Forward Error Correction): Optional redundancy for high-quality mode
- Dynamic Bitrate Adjustment: Adapts to changing network conditions
- Silence Suppression: Reduced data during silence periods
- Prioritization: QoS markings for voice packets
Voice Activity Detection
The protocol uses voice activity detection to minimize bandwidth:
- Client-side detection determines when player is speaking
- Only active voice is transmitted
- Comfort noise is generated during silence
- Brief transmission continues after speech ends to avoid clipping
Security Considerations
Encryption
All chat communication is encrypted using:
- Transport Security: TLS 1.3 for WebSocket/TCP connections
- Content Encryption: AES-256-GCM for message payloads
- Key Exchange: ECDHE for perfect forward secrecy
- Authentication: HMAC-SHA256 for message authentication
Authentication
Messages are authenticated using:
- Session Keys: Generated during game join process
- Message Signing: HMAC signature for each message
- Player Identity: Verified through game server authentication
- Anti-Spoofing: Measures to prevent player impersonation
Privacy Controls
The protocol implements privacy features:
- Muting: Client-side and server-side muting options
- Blocking: Prevent communication from specific players
- Reporting: Ability to report abusive messages with context
- Data Minimization: Limited metadata collection
- Retention Policy: Temporary storage of message history
Bandwidth Optimization
Text Chat Optimization
Text messages use several bandwidth optimization techniques:
- Message Batching: Combine multiple messages when possible
- Compression: zlib compression for larger messages
- Differential Updates: Send only changes for edited messages
- Efficient Encoding: Binary format instead of JSON/XML
- Incremental History: Download message history in chunks
Voice Chat Optimization
Voice transmission is optimized for bandwidth efficiency:
- Codec Selection: Choose appropriate codec based on conditions
- Bitrate Adaptation: Adjust quality based on available bandwidth
- Packet Coalescing: Combine small packets when possible
- Selective Forwarding: Only send voice to players who need it
- Bandwidth Limiter: Cap total voice bandwidth usage
- Mixed-mode: Option to route voice through server when P2P is inefficient
Bandwidth Usage
Typical bandwidth usage per player:
Communication Type | Direction | Bandwidth (Average) | Bandwidth (Peak) |
---|---|---|---|
Text Chat | Upload | 0.1-0.5 KB/s | 2-5 KB/s |
Text Chat | Download | 0.2-1.0 KB/s | 5-10 KB/s |
Voice Chat | Upload | 5-15 KB/s | 20 KB/s |
Voice Chat | Download | 5-15 KB/s per active speaker | 20 KB/s per speaker |
Implementation Details
Protocol Handlers
The protocol is implemented using Bevy's ECS architecture:
#![allow(unused)] fn main() { /// System to handle incoming chat network packets fn handle_chat_network_packets( mut network_receiver: EventReader<NetworkPacketReceived>, mut message_events: EventWriter<ChatMessageEvent>, session_manager: Res<ChatSessionManager>, ) { for packet in network_receiver.read() { if packet.protocol_id != CHAT_PROTOCOL_ID { continue; } // Verify packet integrity if !verify_packet_checksum(&packet.data) { // Log error and discard packet continue; } // Decrypt message let decrypted_data = match decrypt_message( &packet.data, &session_manager.get_session_key(packet.sender_id), ) { Ok(data) => data, Err(e) => { // Log decryption error continue; } }; // Deserialize message let message = match deserialize_chat_message(&decrypted_data) { Ok(msg) => msg, Err(e) => { // Log deserialization error continue; } }; // Process message message_events.send(ChatMessageEvent { message, recipients: get_recipients_from_channel(message.channel), }); } } /// System to send chat messages over the network fn send_chat_network_packets( mut message_events: EventReader<OutgoingChatMessageEvent>, mut network_sender: EventWriter<NetworkPacketSend>, session_manager: Res<ChatSessionManager>, ) { for event in message_events.read() { // Serialize message let serialized_data = match serialize_chat_message(&event.message) { Ok(data) => data, Err(e) => { // Log serialization error continue; } }; // Encrypt message for each recipient for recipient_id in get_recipient_ids(&event.recipients) { let session_key = session_manager.get_session_key(recipient_id); let encrypted_data = match encrypt_message(&serialized_data, &session_key) { Ok(data) => data, Err(e) => { // Log encryption error continue; } }; // Add checksum let final_data = add_checksum(&encrypted_data); // Send packet network_sender.send(NetworkPacketSend { protocol_id: CHAT_PROTOCOL_ID, recipient_id, data: final_data, reliability: PacketReliability::Reliable, channel: NetworkChannel::Chat, }); } } } }
Voice Processing Pipeline
The voice chat processing pipeline:
#![allow(unused)] fn main() { /// System to process and send voice data fn process_and_send_voice( mut audio_input: ResMut<AudioInputBuffer>, mut voice_events: EventWriter<OutgoingVoicePacketEvent>, voice_config: Res<VoiceChatConfig>, voice_activity: Res<VoiceActivityDetector>, encoder: Res<AudioEncoder>, local_player: Res<LocalPlayer>, time: Res<Time>, ) { // Check if player is muted or voice is disabled if !voice_config.enabled || voice_config.is_muted { return; } // Get audio samples from input buffer let raw_samples = audio_input.get_samples(FRAME_SIZE); // Check for voice activity let is_voice_active = voice_config.activation_mode == VoiceActivationMode::AlwaysOn || (voice_config.activation_mode == VoiceActivationMode::VoiceActivated && voice_activity.detect_voice(&raw_samples)); if is_voice_active { // Apply preprocessing (noise suppression, etc.) let processed_samples = preprocess_audio( &raw_samples, voice_config.noise_suppression_level, voice_config.auto_gain_control, ); // Encode audio with selected codec let encoded_data = encoder.encode(&processed_samples); // Determine sequence number let sequence = next_voice_sequence_number(); // Create voice packet let voice_packet = VoicePacket { player_id: local_player.id, sequence, timestamp: time.elapsed_seconds() * 1000.0, channel: voice_config.active_channel, data: encoded_data, }; // Send to network system voice_events.send(OutgoingVoicePacketEvent { packet: voice_packet, }); } } }
Protocol Constants
Key protocol constants:
#![allow(unused)] fn main() { // Protocol identifiers const CHAT_PROTOCOL_ID: u8 = 0x01; const VOICE_PROTOCOL_ID: u8 = 0x02; // Protocol versions const TEXT_PROTOCOL_VERSION: u8 = 0x01; const VOICE_PROTOCOL_VERSION: u8 = 0x01; // Maximum message sizes const MAX_TEXT_MESSAGE_SIZE: usize = 2048; const MAX_VOICE_PACKET_SIZE: usize = 512; // Timing constants const TEXT_RETRY_INTERVAL_MS: u32 = 500; const MAX_TEXT_RETRIES: u32 = 5; const VOICE_FRAME_DURATION_MS: u32 = 20; const MAX_JITTER_BUFFER_MS: u32 = 200; // Rate limiting const MAX_MESSAGES_PER_SECOND: u32 = 5; const MAX_VOICE_BANDWIDTH_KBPS: u32 = 30; }
Error Handling
Network Errors
The protocol handles various network errors:
- Connection Loss: Automatic reconnection with exponential backoff
- Packet Loss: Retransmission for text, concealment for voice
- Latency Spikes: Jitter buffer for voice, acknowledge timeouts for text
- Fragmentation: Message reassembly for large text messages
- MTU Limits: Automatic fragmentation for oversized packets
Protocol Errors
Handling of protocol-level errors:
- Version Mismatch: Negotiation of compatible protocol version
- Malformed Messages: Proper error logging and discarding
- Checksum Failures: Retransmission requests for critical messages
- Decryption Failures: Session renegotiation if persistent
- Sequence Gaps: Reordering or retransmission as appropriate
Error Reporting
Error metrics collected for monitoring:
- Packet Loss Rate: Percentage of packets not received
- Retry Rate: Frequency of message retransmissions
- Decryption Failures: Count of failed decryption attempts
- Latency: Round-trip time for acknowledgments
- Jitter: Variance in packet arrival times
These metrics are used to dynamically adjust protocol parameters for optimal performance.
In-Game Text Chat System
This document provides detailed information about the text chat component of Rummage's in-game communication system.
Table of Contents
- Overview
- UI Components
- Text Chat Features
- Message Types
- Chat Commands
- Implementation Details
- Testing
Overview
The text chat system provides a flexible and intuitive interface for players to communicate during Commander games. It balances ease of use with powerful features, allowing for both casual conversation and game-specific communication.
Key design principles:
- Non-Intrusive: Minimizes screen space usage while maintaining readability
- Context-Aware: Adapts to game state and player actions
- Flexible: Supports various communication needs and styles
- Integrated: Closely tied to game mechanics and events
UI Components
The text chat interface consists of several key components:
Chat Window
The main container for all chat-related UI elements:
┌────────────────────────────────────────────┐
│ [Global] [Team] [Spectators] [Settings] [X]│
├────────────────────────────────────────────┤
│ [System] Game started │
│ Player1: Hello everyone │
│ Player2: Good luck & have fun! │
│ [Event] Player3 casts Lightning Bolt │
│ │
│ │
│ │
│ │
├────────────────────────────────────────────┤
│ Type message... [Send] [📢]│
└────────────────────────────────────────────┘
- Header: Channel tabs, settings button, close/minimize button
- Message Display: Scrollable area showing chat history
- Input Field: Text entry area with send button and voice chat toggle
Notification Badge
When the chat is minimized, notifications appear showing new messages:
┌───────┐
│ Chat 3│
└───────┘
The badge shows the number of unread messages and changes color based on message importance.
Message Bubbles
Individual messages are displayed in styled bubbles with context information:
┌─────────────────────────────────────────┐
│ Player1 (10:42): │
│ Does anyone have a response to this? │
└─────────────────────────────────────────┘
Each message includes:
- Sender name with optional player color
- Timestamp
- Message content with optional formatting
- Message type indicator (system, event, etc.)
Text Chat Features
Chat Channels
The system supports multiple communication channels:
- Global: Messages visible to all players in the game
- Team: Messages visible only to teammates (for team games)
- Private: Direct messages to specific players
- Spectator: Communication with non-playing observers
- System: Game information and event messages
- Event Log: Detailed game action history
Users can switch between channels using tabs or chat commands.
Message Formatting
The chat supports basic text formatting options:
- Emphasis: italic and bold text
- Card References: [[Card Name]] auto-links to card information
- Links: Clickable URLs with preview tooltips
- Emoji: Standard emoji support with game-specific additions
- Color Coding: Optional colored text based on message type or sender
Message Filtering
Users can filter the chat display based on various criteria:
- Channel Filters: Show/hide messages from specific channels
- Type Filters: Show/hide system messages, events, etc.
- Player Filters: Focus on messages from specific players
- Keyword Filters: Highlight or hide messages containing specific terms
Chat History
The chat system maintains a searchable history of messages:
- Scrollback: Browse previous messages within the current session
- Search: Find messages containing specific text or from specific players
- Session Log: Option to save chat history to a file
- Persistence: Optional storage of chat logs between sessions
Message Types
The chat system supports several types of messages:
Player Messages
Standard text messages sent by players:
- Chat Messages: Normal conversation text
- Announcements: Important player notifications
- Responses: Contextual replies to other messages or game events
System Messages
Automated messages from the game system:
- Game Events: Card plays, battlefield changes, life total updates
- Phase Updates: Turn phase transitions, priority changes
- Timer Notifications: Round time, turn time warnings
- Game Status: Win conditions, player eliminations, etc.
Command Messages
Special messages that trigger game actions or chat functions:
- Chat Commands: Messages starting with / that invoke special functions
- Quick Commands: Pre-defined messages accessible through hotkeys
- Emote Commands: Text triggers for emote animations
Chat Commands
The chat system includes command functionality for quick actions:
Core Commands
Essential commands available to all players:
- /help: Display available commands
- /msg [player] [text]: Send private message
- /clear: Clear chat history
- /mute [player]: Mute specified player
- /unmute [player]: Unmute specified player
Game Commands
Commands that provide game information:
- /life [player]: Show player's current life total
- /card [card name]: Display card information
- /hand: Show number of cards in each player's hand
- /graveyard [player]: List cards in player's graveyard
Social Commands
Commands for social interaction:
- /emote [emote name]: Display an emote
- /roll [X]d[Y]: Roll X Y-sided dice
- /flip: Flip a coin
- /timer [seconds]: Set a countdown timer
Implementation Details
The text chat is implemented using Bevy's ECS architecture:
Data Structures
#![allow(unused)] fn main() { /// Represents a single chat message #[derive(Clone, Debug)] struct ChatMessage { sender_id: Option<PlayerId>, sender_name: String, content: String, timestamp: f64, channel: ChatChannel, message_type: MessageType, formatting: Option<MessageFormatting>, } /// Defines available chat channels #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] enum ChatChannel { Global, Team, Private(PlayerId), Spectator, System, EventLog, } /// Defines message types for styling and filtering #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum MessageType { Normal, System, Event, Error, Whisper, Command, Emote, } /// Event for chat message transmission #[derive(Event)] struct ChatMessageEvent { message: ChatMessage, recipients: MessageRecipients, } /// Defines message recipients #[derive(Clone, Debug)] enum MessageRecipients { All, Team(TeamId), Player(PlayerId), Spectators, } }
Components
#![allow(unused)] fn main() { /// Component for the chat message display area #[derive(Component)] struct ChatMessageArea { visible_channels: HashSet<ChatChannel>, filter_settings: ChatFilterSettings, max_messages: usize, scroll_position: f32, } /// Component for the chat input field #[derive(Component)] struct ChatInputField { text: String, cursor_position: usize, history: Vec<String>, history_position: Option<usize>, target_channel: ChatChannel, } /// Component for individual message entities #[derive(Component)] struct ChatMessageEntity { message: ChatMessage, is_read: bool, animation_state: MessageAnimationState, } }
Message Rendering System
#![allow(unused)] fn main() { /// System to render chat messages fn render_chat_messages( mut commands: Commands, mut message_area_query: Query<(&mut ChatMessageArea, &Children)>, message_query: Query<(Entity, &ChatMessageEntity)>, message_events: EventReader<ChatMessageEvent>, chat_settings: Res<ChatSettings>, asset_server: Res<AssetServer>, time: Res<Time>, ) { // Process new messages for event in message_events.read() { for (mut message_area, children) in &mut message_area_query { // Check if message should be displayed in this area if !message_area.visible_channels.contains(&event.message.channel) { continue; } // Apply filters if !passes_filters(&event.message, &message_area.filter_settings) { continue; } // Create new message entity let message_entity = spawn_message_entity( &mut commands, &event.message, &asset_server, &chat_settings ); // Add to message area commands.entity(message_area_entity).add_child(message_entity); // Limit number of messages if children.len() > message_area.max_messages { // Remove oldest message if let Some(oldest) = children.first() { commands.entity(*oldest).despawn_recursive(); } } // Auto-scroll to new message message_area.scroll_position = 1.0; } } // Update message animations for (entity, message) in &message_query { // Update animation state based on time // ... } } }
Chat Input System
#![allow(unused)] fn main() { /// System to handle chat input fn handle_chat_input( mut commands: Commands, mut input_query: Query<&mut ChatInputField>, keyboard_input: Res<ButtonInput<KeyCode>>, mut chat_events: EventWriter<ChatMessageEvent>, player_query: Query<(&Player, &PlayerId)>, time: Res<Time>, ) { for mut input_field in &mut input_query { // Check for Enter key to send message if keyboard_input.just_pressed(KeyCode::Return) { if !input_field.text.is_empty() { // Create message from input let message = create_message_from_input( &input_field.text, &player_query, input_field.target_channel, time.elapsed_seconds(), ); // Handle commands if input_field.text.starts_with('/') { process_command(&input_field.text, &mut chat_events); } else { // Send regular message chat_events.send(ChatMessageEvent { message, recipients: get_recipients_for_channel(input_field.target_channel), }); } // Add to history and clear input input_field.history.push(input_field.text.clone()); input_field.text.clear(); input_field.cursor_position = 0; input_field.history_position = None; } } // Handle Up/Down for history navigation if keyboard_input.just_pressed(KeyCode::Up) { navigate_history_up(&mut input_field); } if keyboard_input.just_pressed(KeyCode::Down) { navigate_history_down(&mut input_field); } // Handle Tab for channel switching if keyboard_input.just_pressed(KeyCode::Tab) { cycle_chat_channel(&mut input_field); } } } }
Testing
The text chat component requires thorough testing:
Unit Tests
#![allow(unused)] fn main() { #[test] fn test_message_filtering() { // Create test app let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_event::<ChatMessageEvent>() .add_systems(Update, chat_systems::filter_messages); // Setup test state let filter_settings = ChatFilterSettings { show_system_messages: false, show_event_messages: true, // ... }; app.world.insert_resource(filter_settings); // Create test messages let system_message = ChatMessage { message_type: MessageType::System, // ... }; let event_message = ChatMessage { message_type: MessageType::Event, // ... }; // Send test messages app.world.send_event(ChatMessageEvent { message: system_message, recipients: MessageRecipients::All, }); app.world.send_event(ChatMessageEvent { message: event_message, recipients: MessageRecipients::All, }); // Run systems app.update(); // Verify filtering let visible_messages = app.world.query::<&ChatMessageEntity>().iter(&app.world).collect::<Vec<_>>(); assert_eq!(visible_messages.len(), 1, "Only event message should be visible"); assert_eq!(visible_messages[0].message.message_type, MessageType::Event); } }
Integration Tests
#![allow(unused)] fn main() { #[test] fn test_chat_command_processing() { // Create test app with necessary plugins let mut app = App::new(); app.add_plugins(GameUiTestPlugins) .add_systems(Update, ( chat_systems::handle_chat_input, chat_systems::process_commands, )); // Setup test game with players setup_test_game(&mut app, 2); // Get chat input entity let input_entity = app.world.query_filtered::<Entity, With<ChatInputField>>().single(&app.world); // Simulate typing a command let mut input_field = app.world.get_mut::<ChatInputField>(input_entity).unwrap(); input_field.text = "/roll 2d6".to_string(); // Simulate Enter key press app.world.send_event(KeyboardInput { key: KeyCode::Return, state: ButtonState::Pressed, }); // Run systems app.update(); // Verify command was processed let messages = app.world.query::<&ChatMessageEntity>().iter(&app.world).collect::<Vec<_>>(); // Should have a system message with dice roll results assert!(messages.iter().any(|m| m.message.message_type == MessageType::System && m.message.content.contains("rolled 2d6") ), "Dice roll command should create system message"); } }
Performance Tests
#![allow(unused)] fn main() { #[test] fn test_chat_performance_with_many_messages() { // Create test app let mut app = App::new(); app.add_plugins(GameUiTestPlugins) .add_plugins(DiagnosticsPlugin); // Setup chat window let chat_entity = app.world.spawn(( ChatWindow { /* ... */ }, ChatMessageArea { max_messages: 100, // ... }, // ... )).id(); // Generate many test messages let messages = (0..500).map(|i| ChatMessage { content: format!("Test message {}", i), // ... }).collect::<Vec<_>>(); // Measure performance while adding messages let mut frame_times = Vec::new(); for message in messages { app.world.send_event(ChatMessageEvent { message, recipients: MessageRecipients::All, }); let start = std::time::Instant::now(); app.update(); frame_times.push(start.elapsed()); } // Calculate average frame time let avg_frame_time = frame_times.iter().sum::<std::time::Duration>() / frame_times.len() as u32; // Ensure performance remains acceptable assert!(avg_frame_time.as_millis() < 16, "Chat should maintain 60+ FPS with many messages"); } }
In-Game Voice Chat System
This document provides detailed information about the voice chat component of Rummage's in-game communication system.
Table of Contents
- Overview
- UI Components
- Core Features
- Audio Controls
- Integration With Game State
- Implementation Details
- Testing
Overview
The voice chat system provides real-time audio communication between players during Commander games. It enhances the social experience of playing Magic: The Gathering remotely by adding a layer of direct communication that complements the text chat system.
Key design principles:
- Low Latency: Prioritizes minimal delay for natural conversation
- Clarity: Emphasizes audio quality for clear communication
- Accessibility: Provides alternative options for those who can't use voice
- Non-Intrusive: Integrates with gameplay without disruption
- Resource Efficient: Minimizes performance impact
UI Components
The voice chat interface consists of several components that integrate with the main chat UI:
Voice Activation Controls
┌─────────────────────────────┐
│ [🎤] Push to Talk [⚙️] [📊] │
└─────────────────────────────┘
- Microphone Button: Toggle for enabling/disabling voice input
- Mode Selector: Switch between Push-to-Talk and Voice Activation
- Settings Button: Quick access to voice settings
- Voice Level Indicator: Shows current microphone input level
Speaker Status Indicator
┌──────────────────────────────────┐
│ Player1 [🔊] Player2 [🔊] [🔇] │
└──────────────────────────────────┘
- Player List: Shows all players in the game
- Speaker Icon: Animated icon showing who is currently speaking
- Volume Controls: Per-player volume adjustment
- Mute Button: Quick toggle to mute all voice chat
Voice Settings Panel
┌───────────────────────────────────────┐
│ Voice Chat Settings [X] │
├───────────────────────────────────────┤
│ Input Device: [Microphone▼] │
│ Output Device: [Speakers▼] │
│ │
│ Input Volume: [==========] 80% │
│ Output Volume: [========--] 60% │
│ │
│ Voice Activation Level: [===-------] │
│ │
│ [✓] Noise Suppression │
│ [✓] Echo Cancellation │
│ [ ] Automatically Adjust Levels │
│ │
│ Push-to-Talk Key: [Space] │
│ │
│ [Reset to Defaults] [Apply] │
└───────────────────────────────────────┘
- Device Selection: Input and output device configuration
- Volume Controls: Master volume adjustments
- Activation Settings: Voice detection sensitivity
- Audio Processing: Noise suppression and echo cancellation options
- Key Bindings: Configure Push-to-Talk keys
Voice Activity Indicators
Visual cues integrated into player avatars:
- Glowing Border: Indicates a player is speaking
- Volume Level: Shows relative volume of speaking player
- Mute Indicator: Shows when a player is muted or has muted themselves
Core Features
Voice Activation Modes
The system supports multiple ways to activate voice transmission:
- Push-to-Talk: Requires holding a key to transmit voice
- Voice Activation: Automatically transmits when speech is detected
- Always On: Continuously transmits audio (with optional noise gate)
- Priority Speaker: Option for game host to override other speakers
Audio Quality Settings
Configurable audio quality options:
- Quality Presets: Low, Medium, High, and Ultra profiles
- Bandwidth Control: Automatic adjustment based on network conditions
- Sample Rate: Options from 16 kHz to 48 kHz
- Bit Depth: 16-bit or 24-bit audio
Channel Management
Support for multiple audio channels:
- Global Voice: Heard by all players in the game
- Team Voice: Private channel for team members in team games
- Private Call: One-on-one communication between specific players
- Spectator Channel: Communication with non-playing observers
Audio Controls
Input Controls
Options for managing voice input:
- Microphone Selection: Choose between available input devices
- Input Gain: Adjust microphone sensitivity
- Noise Gate: Filter out background noise below threshold
- Push-to-Talk Delay: Set release timing to avoid cutting off words
- Automatic Gain Control: Maintain consistent input volume
Output Controls
Options for managing voice output:
- Speaker Selection: Choose between available output devices
- Master Volume: Overall voice chat volume control
- Per-Player Volume: Individual volume controls for each player
- Audio Panning: Spatial positioning of voices (disabled by default)
- Ducking: Option to reduce game sound when others are speaking
Audio Processing
Features for improving voice quality:
- Noise Suppression: Reduce background noise
- Echo Cancellation: Prevent feedback and echo
- Voice Clarity Enhancement: Emphasis on speech frequencies
- Automatic Level Adjustment: Balance volume between different players
Integration With Game State
The voice chat system adapts to the current game state:
Game Phase Integration
- Planning Phase: Normal voice operation
- Active Phase: Option to highlight speaking player's cards
- End Phase: Notification sounds for voice chat
Player Status Integration
- Disconnected Players: Automatic muting with indicator
- AFK Detection: Automatic muting after period of inactivity
- Priority Status: Visual indicator when speaking player has priority
Contextual Features
- Positional Audio: Optional feature to position voices based on player's virtual position
- Effect Filters: Special voice effects for certain game actions (disabled by default)
- Auto-Mute: Option to mute voice during cinematic moments
Implementation Details
The voice chat is implemented using Bevy's ECS architecture with additional audio processing libraries:
Data Structures
#![allow(unused)] fn main() { /// Voice chat configuration #[derive(Resource)] struct VoiceChatConfig { enabled: bool, input_device_id: Option<String>, output_device_id: Option<String>, input_volume: f32, output_volume: f32, activation_mode: VoiceActivationMode, activation_threshold: f32, push_to_talk_key: KeyCode, noise_suppression_level: NoiseSuppressionLevel, echo_cancellation: bool, auto_gain_control: bool, } /// Voice activation modes #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum VoiceActivationMode { PushToTalk, VoiceActivated, AlwaysOn, Disabled, } /// Noise suppression levels #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum NoiseSuppressionLevel { Off, Low, Medium, High, Maximum, } /// Voice activity status for a player #[derive(Component)] struct VoiceActivityStatus { player_id: PlayerId, is_speaking: bool, is_muted: bool, is_muted_by_local: bool, volume_level: f32, peak_level: f32, } /// Voice chat audio buffer #[derive(Resource)] struct VoiceAudioBuffer { input_buffer: Vec<f32>, output_buffer: HashMap<PlayerId, Vec<f32>>, buffer_size: usize, sample_rate: u32, } /// Voice packet for network transmission #[derive(Event)] struct VoicePacketEvent { player_id: PlayerId, audio_data: Vec<u8>, sequence_number: u32, timestamp: f64, channel: VoiceChannel, } }
Components
#![allow(unused)] fn main() { /// Component for the voice chat UI controller #[derive(Component)] struct VoiceChatController { is_settings_open: bool, is_expanded: bool, active_channel: VoiceChannel, } /// Component for voice input indicators #[derive(Component)] struct VoiceInputIndicator { level: f32, speaking_confidence: f32, is_active: bool, } /// Component for player voice indicators #[derive(Component)] struct PlayerVoiceIndicator { player_id: PlayerId, is_speaking: bool, } }
Audio Capture System
#![allow(unused)] fn main() { /// System to capture and process microphone input fn capture_microphone_input( mut audio_buffer: ResMut<VoiceAudioBuffer>, voice_config: Res<VoiceChatConfig>, keyboard_input: Res<ButtonInput<KeyCode>>, audio_input: Res<AudioInputDevice>, mut voice_events: EventWriter<VoicePacketEvent>, time: Res<Time>, local_player: Res<LocalPlayer>, ) { // Skip if voice chat is disabled if !voice_config.enabled { return; } // Check activation mode let should_capture = match voice_config.activation_mode { VoiceActivationMode::PushToTalk => { keyboard_input.pressed(voice_config.push_to_talk_key) }, VoiceActivationMode::VoiceActivated => { // Detect voice using energy threshold let energy = calculate_audio_energy(&audio_input); energy > voice_config.activation_threshold }, VoiceActivationMode::AlwaysOn => true, VoiceActivationMode::Disabled => false, }; if should_capture { // Capture audio from microphone let input_samples = audio_input.capture_samples(audio_buffer.buffer_size); // Apply audio processing (noise suppression, etc.) let processed_samples = process_audio_samples( &input_samples, voice_config.noise_suppression_level, voice_config.echo_cancellation, ); // Compress audio for network transmission let compressed_data = compress_audio_data(&processed_samples); // Send voice packet voice_events.send(VoicePacketEvent { player_id: local_player.id, audio_data: compressed_data, sequence_number: next_sequence_number(), timestamp: time.elapsed_seconds(), channel: voice_config.active_channel, }); } } }
Audio Playback System
#![allow(unused)] fn main() { /// System to play received voice audio fn play_voice_audio( mut audio_buffer: ResMut<VoiceAudioBuffer>, voice_config: Res<VoiceChatConfig>, voice_status: Query<&VoiceActivityStatus>, mut voice_events: EventReader<VoicePacketEvent>, audio_output: Res<AudioOutputDevice>, mut player_indicators: Query<(&mut PlayerVoiceIndicator, &mut BackgroundColor)>, ) { // Process incoming voice packets for packet in voice_events.read() { // Skip packets if voice is disabled if !voice_config.enabled { continue; } // Skip packets from muted players if let Ok(status) = voice_status.get_component::<VoiceActivityStatus>(packet.player_id) { if status.is_muted || status.is_muted_by_local { continue; } } // Decompress audio data let decompressed_data = decompress_audio_data(&packet.audio_data); // Apply volume adjustment let volume_adjusted = adjust_volume( &decompressed_data, voice_config.output_volume * get_player_volume(packet.player_id), ); // Add to output buffer audio_buffer.output_buffer.insert(packet.player_id, volume_adjusted); // Update speaking indicators for (mut indicator, mut background) in &mut player_indicators { if indicator.player_id == packet.player_id { indicator.is_speaking = true; // Change background to indicate speaking *background = BackgroundColor(Color::rgba(0.2, 0.8, 0.2, 0.5)); } } } // Mix and play output buffer let mixed_output = mix_audio_channels(&audio_buffer.output_buffer); audio_output.play_samples(&mixed_output); // Reset output buffer audio_buffer.output_buffer.clear(); // Reset speaking indicators after delay // This would typically be done with a timer system // Simplified for documentation purposes } }
Voice Chat UI Update System
#![allow(unused)] fn main() { /// System to update voice chat UI fn update_voice_chat_ui( mut voice_controller_query: Query<(&mut VoiceChatController, &Children)>, mut indicator_query: Query<(&mut VoiceInputIndicator, &mut Node)>, voice_status_query: Query<&VoiceActivityStatus>, voice_config: Res<VoiceChatConfig>, audio_input: Res<AudioInputDevice>, keyboard_input: Res<ButtonInput<KeyCode>>, ) { for (mut controller, children) in &mut voice_controller_query { // Toggle settings panel if keyboard_input.just_pressed(KeyCode::F7) { controller.is_settings_open = !controller.is_settings_open; } // Update input indicators for (mut indicator, mut node) in &mut indicator_query { // Update microphone level indicator let current_level = if voice_config.enabled { calculate_audio_level(audio_input.get_level()) } else { 0.0 }; indicator.level = smooth_level_transition(indicator.level, current_level, 0.1); // Update indicator visual size based on level let indicator_height = Val::Px(20.0 + indicator.level * 30.0); if node.height != indicator_height { node.height = indicator_height; } // Update active state indicator.is_active = match voice_config.activation_mode { VoiceActivationMode::PushToTalk => { keyboard_input.pressed(voice_config.push_to_talk_key) }, VoiceActivationMode::VoiceActivated => { indicator.level > voice_config.activation_threshold }, VoiceActivationMode::AlwaysOn => true, VoiceActivationMode::Disabled => false, }; } } } }
Testing
The voice chat component requires thorough testing:
Unit Tests
#![allow(unused)] fn main() { #[test] fn test_voice_activation_detection() { // Create test app let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, voice_systems::detect_voice_activity); // Setup test resources app.insert_resource(VoiceChatConfig { activation_mode: VoiceActivationMode::VoiceActivated, activation_threshold: 0.05, // ... }); // Create mock audio data let silent_audio = vec![0.01f32; 1024]; let speaking_audio = vec![0.2f32; 1024]; // Test silent audio app.world.insert_resource(MockAudioInput { samples: silent_audio.clone(), }); app.update(); let is_active = app.world.resource::<VoiceActivityState>().is_active; assert!(!is_active, "Should not detect voice in silent audio"); // Test speaking audio app.world.insert_resource(MockAudioInput { samples: speaking_audio.clone(), }); app.update(); let is_active = app.world.resource::<VoiceActivityState>().is_active; assert!(is_active, "Should detect voice in speaking audio"); } }
Integration Tests
#![allow(unused)] fn main() { #[test] fn test_voice_chat_network_integration() { // Create test app with networking mockup let mut app = App::new(); app.add_plugins(GameUiTestPlugins) .add_plugins(MockNetworkPlugin) .add_systems(Update, ( voice_systems::capture_microphone_input, voice_systems::transmit_voice_packets, voice_systems::receive_voice_packets, voice_systems::play_voice_audio, )); // Setup test game with multiple players let (local_id, remote_id) = setup_test_players(&mut app); // Setup mock audio input with test samples let test_audio = generate_test_audio_samples(); app.insert_resource(MockAudioInput { samples: test_audio }); // Setup voice activation app.insert_resource(VoiceChatConfig { enabled: true, activation_mode: VoiceActivationMode::AlwaysOn, // ... }); // Run capture and transmission app.update(); // Verify packets were sent let sent_packets = app.world.resource::<MockNetwork>().sent_packets.clone(); assert!(!sent_packets.is_empty(), "Voice packets should be sent"); // Simulate receiving packets from the remote player let mut mock_network = app.world.resource_mut::<MockNetwork>(); mock_network.simulate_receive(VoicePacketEvent { player_id: remote_id, audio_data: mock_network.sent_packets[0].audio_data.clone(), sequence_number: 1, timestamp: 0.0, channel: VoiceChannel::Global, }); // Run receive and playback app.update(); // Verify audio was queued for playback let output_device = app.world.resource::<MockAudioOutput>(); assert!(!output_device.played_samples.is_empty(), "Voice audio should be played"); // Verify UI indicators were updated let indicators = app.world.query::<&PlayerVoiceIndicator>() .iter(&app.world) .find(|i| i.player_id == remote_id && i.is_speaking); assert!(indicators.is_some(), "Remote player should be marked as speaking"); } }
Performance Tests
#![allow(unused)] fn main() { #[test] fn test_voice_chat_performance() { // Create test app let mut app = App::new(); app.add_plugins(GameUiTestPlugins) .add_plugins(DiagnosticsPlugin); // Setup voice chat with max players setup_voice_chat_with_players(&mut app, 6); // Add performance measurement systems app.add_systems(Update, measure_performance); // Generate test audio for all players let test_audio = generate_multi_player_audio(); app.insert_resource(MockMultiPlayerAudio { samples: test_audio }); // Run system for multiple frames let mut cpu_usage = Vec::new(); let mut memory_usage = Vec::new(); for _ in 0..100 { let start = std::time::Instant::now(); app.update(); let frame_time = start.elapsed(); cpu_usage.push(frame_time); // Measure memory usage let mem_usage = app.world.resource::<MemoryDiagnostics>().current_usage; memory_usage.push(mem_usage); } // Calculate average CPU and memory usage let avg_cpu = cpu_usage.iter().sum::<std::time::Duration>() / cpu_usage.len() as u32; let avg_memory = memory_usage.iter().sum::<usize>() / memory_usage.len(); // Check against performance targets assert!(avg_cpu.as_millis() < 5, "Voice processing should use less than 5ms per frame"); assert!(avg_memory < 10 * 1024 * 1024, "Voice chat should use less than 10MB memory"); } }
Network Tests
#![allow(unused)] fn main() { #[test] fn test_voice_chat_bandwidth_usage() { // Create test app with network diagnostics let mut app = App::new(); app.add_plugins(GameUiTestPlugins) .add_plugins(NetworkDiagnosticsPlugin); // Setup voice chat app.insert_resource(VoiceChatConfig { enabled: true, activation_mode: VoiceActivationMode::AlwaysOn, // ... }); // Setup mock audio with constant speaking app.insert_resource(MockAudioInput { samples: generate_speaking_audio(), }); // Run for multiple frames let network_usage = Vec::new(); for _ in 0..100 { app.update(); let diagnostics = app.world.resource::<NetworkDiagnostics>(); network_usage.push(diagnostics.bytes_sent + diagnostics.bytes_received); } // Calculate average bandwidth let avg_bandwidth = network_usage.iter().sum::<usize>() / network_usage.len(); // Voice chat should use reasonable bandwidth (30 KB/s maximum) assert!(avg_bandwidth < 30 * 1024, "Voice chat should use less than 30 KB/s bandwidth"); } }
Player Avatars
This document describes the player avatar system in Rummage, which provides visual representation of players in the game interface.
Table of Contents
- Overview
- Avatar Components
- Visual Representation
- Player State Indicators
- Customization
- Implementation Details
- Testing
Overview
The avatar system provides a visual representation of each player in the Commander game. Avatars help players:
- Quickly identify who controls which game elements
- See player status information at a glance
- Express identity through customization
- Visualize social interactions and game effects
Avatar Components
Each player avatar consists of multiple visual components:
Core Components
- Profile Picture: The primary visual identity of the player
- Name Plate: Displays the player's username
- Life Counter: Shows current life total with emphasis on changes
- Priority Indicator: Shows when a player has priority
- Turn Indicator: Highlights the active player
- Commander Damage Tracker: Displays commander damage received from each opponent
Example Avatar Layout
┌────────────────────────────────────┐
│ ┌──────┐ Username │
│ │ │ Life: 40 ▲2 │
│ │ IMG │ │
│ │ │ ⚡ Priority │
│ └──────┘ │
├────────────────────────────────────┤
│ CMD DMG: P1(0) P2(6) P3(0) P4(0) │
└────────────────────────────────────┘
Visual Representation
Avatars appear in different locations depending on the context:
In-Game Placement
- Local Player: Usually positioned at the bottom of the screen
- Opponents: Positioned around the virtual table based on turn order
- Active Player: Receives visual emphasis through highlighting or scaling
Avatar Sizing
Avatars adapt to different contexts:
- Full Size: In player information panels
- Medium Size: At the head of playmat areas
- Small Size: Near cards to indicate control
- Minimal: In chat and notification areas
Player State Indicators
Avatars reflect player state through visual cues:
Game State Indicators
- Turn Status: Highlight for active player
- Priority Status: Indicator when player has priority
- Thinking Status: Animation when player is taking an action
- Passed Status: Indicator when player has passed priority
Player Status Indicators
- Life Total: Shows current life with animations for changes
- Hand Size: Indicates number of cards in hand
- Disconnected Status: Indicator for disconnected players
- Away Status: Indicator for temporarily inactive players
Hand Status Indicators
- Card Count: Shows number of cards in hand
- Mulligan Status: Indicates if player is mulliganing
- Drawing Status: Animation when drawing cards
Customization
Players can customize their avatars to express identity:
Visual Customization
- Profile Pictures: Select from built-in options or upload custom images
- Borders: Decorative frames around profile pictures
- Effects: Special visual effects for achievements or rankings
- Color Schemes: Customize colors of avatar UI elements
Indicator Customization
- Life Counter Style: Different visualization styles
- Animation Types: Preference for animation effects
- Sound Effects: Custom sounds for avatar-related events
Implementation Details
Avatars are implemented using Bevy's ECS system:
#![allow(unused)] fn main() { /// Component for player avatars #[derive(Component)] struct PlayerAvatar { player_id: PlayerId, display_name: String, profile_image: Handle<Image>, border_style: AvatarBorderStyle, customization_settings: AvatarCustomization, } /// Component for avatar life counter display #[derive(Component)] struct AvatarLifeCounter { player_id: PlayerId, current_life: i32, previous_life: i32, animation_timer: Timer, is_animating: bool, } /// Setup function for player avatars fn setup_player_avatar( mut commands: Commands, player: &Player, asset_server: &Res<AssetServer>, position: Vec2, ) -> Entity { // Default profile image let profile_image = player.profile_image.clone() .unwrap_or_else(|| asset_server.load("textures/avatars/default.png")); commands .spawn(( PlayerAvatar { player_id: player.id, display_name: player.name.clone(), profile_image: profile_image.clone(), border_style: AvatarBorderStyle::default(), customization_settings: player.avatar_customization.clone(), }, Node { width: Val::Px(280.0), height: Val::Px(80.0), flex_direction: FlexDirection::Row, padding: UiRect::all(Val::Px(5.0)), ..default() }, BackgroundColor(Color::rgba(0.1, 0.1, 0.1, 0.8)), BorderColor(Color::rgb(0.6, 0.6, 0.6)), Outline::new(Val::Px(1.0)), Transform::from_translation(Vec3::new(position.x, position.y, 0.0)), GlobalTransform::default(), AppLayer::GameUI.layer(), )) .with_children(|parent| { // Profile image container parent .spawn(Node { width: Val::Px(70.0), height: Val::Px(70.0), margin: UiRect::right(Val::Px(10.0)), ..default() }) .with_children(|image_container| { // Profile image image_container.spawn(( Sprite { custom_size: Some(Vec2::new(70.0, 70.0)), ..default() }, profile_image, )); }); // Information container parent .spawn(Node { width: Val::Percent(100.0), height: Val::Percent(100.0), flex_direction: FlexDirection::Column, justify_content: JustifyContent::SpaceBetween, ..default() }) .with_children(|info| { // Username info.spawn(( Text2d { text: player.name.clone(), font_size: 18.0, color: Color::WHITE, }, )); // Life counter info.spawn(( Text2d { text: format!("Life: {}", player.life), font_size: 16.0, color: Color::rgb(0.8, 0.8, 0.8), }, AvatarLifeCounter { player_id: player.id, current_life: player.life, previous_life: player.life, animation_timer: Timer::from_seconds(0.5, TimerMode::Once), is_animating: false, }, )); // Priority indicator (hidden by default) info.spawn(( Text2d { text: "⚡ Priority".to_string(), font_size: 16.0, color: Color::YELLOW, }, PriorityIndicator { player_id: player.id, visible: false }, Visibility::Hidden, )); }); }) .id() } }
Life Total Update System
#![allow(unused)] fn main() { fn update_avatar_life_counters( time: Res<Time>, mut life_counter_query: Query<(&mut AvatarLifeCounter, &mut Text2d)>, player_query: Query<(&Player, &PlayerId)>, ) { for (player, player_id) in player_query.iter() { for (mut life_counter, mut text) in life_counter_query.iter_mut() { if life_counter.player_id == *player_id { // Check if life has changed if player.life != life_counter.current_life { // Update previous life for animation life_counter.previous_life = life_counter.current_life; life_counter.current_life = player.life; life_counter.is_animating = true; life_counter.animation_timer.reset(); } // Update text display if life_counter.is_animating { life_counter.animation_timer.tick(time.delta()); // Determine color based on life change let life_change = life_counter.current_life - life_counter.previous_life; let color = if life_change > 0 { Color::GREEN } else if life_change < 0 { Color::RED } else { Color::WHITE }; // Format text with change indicator let change_text = if life_change > 0 { format!("▲{}", life_change) } else if life_change < 0 { format!("▼{}", life_change.abs()) } else { String::new() }; text.text = format!("Life: {} {}", life_counter.current_life, change_text); text.color = color; // End animation after timer completes if life_counter.animation_timer.finished() { life_counter.is_animating = false; text.color = Color::WHITE; text.text = format!("Life: {}", life_counter.current_life); } } } } } } }
Priority Indicator System
#![allow(unused)] fn main() { fn update_priority_indicators( mut priority_query: Query<(&mut Visibility, &PriorityIndicator)>, game_state: Res<GameState>, ) { // Find player with priority let priority_player_id = game_state.priority_player; // Update all priority indicators for (mut visibility, indicator) in priority_query.iter_mut() { if indicator.player_id == priority_player_id { *visibility = Visibility::Visible; } else { *visibility = Visibility::Hidden; } } } }
Testing
The avatar system should be thoroughly tested to ensure proper functionality and visual appearance.
Unit Tests
- Test initialization with different player data
- Verify life counter updates correctly
- Ensure priority indicators toggle correctly
- Test avatar positioning logic
Visual Tests
- Verify appearance across different screen sizes
- Test with varied life totals
- Verify animations for life changes
- Ensure visibility of all avatar elements
Integration Tests
- Test avatar updates with game state changes
- Verify commander damage display updates correctly
- Test avatar interactions with turn system
Avatar Selection
Custom Avatars
Accessibility
Screen Reader Support
This document describes how Rummage supports screen readers and implements accessible UI components.
Overview
Accessibility is a core principle of Rummage's UI design. Screen reader support ensures that players with visual impairments can fully experience the game. Implementing proper screen reader support also benefits all players by providing clearer communication of game state and actions.
Architecture
Rummage's screen reader support is built on these key components:
- Accessible Node Components: Bevy components that provide semantic information
- Screen Reader Bridge: System for communicating with platform screen reader APIs
- Focus Management: System for tracking and managing UI focus
- Keyboard Navigation: Support for navigating UI without a mouse
Accessible Components
Each UI component implements the AccessibleNode
component:
#![allow(unused)] fn main() { #[derive(Component)] pub struct AccessibleNode { /// Human-readable label for the element pub label: String, /// Semantic role of the element pub role: AccessibilityRole, /// Current state of the element pub state: AccessibilityState, /// Additional properties pub properties: HashMap<String, String>, } #[derive(PartialEq, Eq, Clone, Copy, Debug)] pub enum AccessibilityRole { Button, Card, Checkbox, Dialog, Grid, GridCell, Image, Link, List, ListItem, Menu, MenuItem, Slider, Tab, TabPanel, Text, // Game-specific roles GameZone, PlayerInfo, PhaseIndicator, } #[derive(Default)] pub struct AccessibilityState { pub disabled: bool, pub selected: bool, pub focused: bool, pub expanded: bool, pub pressed: bool, } }
Implementation Examples
Card Component
Here's how a card component implements screen reader accessibility:
#![allow(unused)] fn main() { fn spawn_card_entity( commands: &mut Commands, card_data: &CardData, ) -> Entity { commands.spawn(( // Visual components SpriteBundle { ... }, // Game logic components Card { ... }, // Accessibility component AccessibleNode { label: format!("{}, {}", card_data.name, card_data.type_line), role: AccessibilityRole::Card, state: AccessibilityState { selected: false, ..default() }, properties: { let mut props = HashMap::new(); props.insert("power".to_string(), card_data.power.to_string()); props.insert("toughness".to_string(), card_data.toughness.to_string()); props.insert("rules_text".to_string(), card_data.rules_text.clone()); props }, }, )).id() } }
Game Zone
Game zones are important landmarks for screen reader navigation:
#![allow(unused)] fn main() { fn spawn_hand_zone(commands: &mut Commands) -> Entity { commands.spawn(( // Visual components NodeBundle { ... }, // Game zone component HandZone { ... }, // Accessibility component AccessibleNode { label: "Hand".to_string(), role: AccessibilityRole::GameZone, state: default(), properties: { let mut props = HashMap::new(); props.insert("card_count".to_string(), "0".to_string()); props }, }, )).id() } }
Focus Management
The focus management system tracks which element has keyboard focus:
#![allow(unused)] fn main() { fn update_focus( keyboard_input: Res<Input<KeyCode>>, mut focused_entity: ResMut<FocusedEntity>, mut query: Query<(Entity, &mut AccessibleNode)>, ) { // Handle Tab navigation if keyboard_input.just_pressed(KeyCode::Tab) { let shift = keyboard_input.pressed(KeyCode::ShiftLeft) || keyboard_input.pressed(KeyCode::ShiftRight); if shift { focused_entity.focus_previous(&mut query); } else { focused_entity.focus_next(&mut query); } } // Update accessibility state based on focus for (entity, mut node) in query.iter_mut() { node.state.focused = Some(entity) == focused_entity.0; } } }
Screen Reader Announcements
The game communicates important events to screen readers:
#![allow(unused)] fn main() { fn announce_phase_change( mut phase_events: EventReader<PhaseChangeEvent>, mut screen_reader: ResMut<ScreenReaderBridge>, ) { for event in phase_events.read() { let announcement = format!( "Phase changed to {} for player {}", event.new_phase.name(), event.active_player.name ); screen_reader.announce(announcement); } } }
Card State Announcements
Changes to card state are announced to the screen reader:
#![allow(unused)] fn main() { fn announce_card_state_changes( mut card_events: EventReader<CardStateChangeEvent>, mut screen_reader: ResMut<ScreenReaderBridge>, card_query: Query<&Card>, ) { for event in card_events.read() { if let Ok(card) = card_query.get(event.card_entity) { let announcement = match event.state_change { CardStateChange::Tapped => format!("{} tapped", card.name), CardStateChange::Untapped => format!("{} untapped", card.name), CardStateChange::Destroyed => format!("{} destroyed", card.name), CardStateChange::Exiled => format!("{} exiled", card.name), // Other state changes }; screen_reader.announce(announcement); } } } }
Keyboard Shortcuts
Rummage provides comprehensive keyboard shortcuts for gameplay:
Key | Action |
---|---|
Space | Select/Activate focused element |
Tab | Move focus to next element |
Shift+Tab | Move focus to previous element |
Arrow keys | Navigate within a zone or component |
1-9 | Select cards in hand |
P | Pass priority |
M | Open mana pool |
C | View card details |
Esc | Cancel current action |
Testing Screen Reader Support
Screen reader support is tested through:
- Unit tests: Test the accessibility components and focus management
- Integration tests: Test the screen reader bridge with mock screen readers
- End-to-end tests: Test with actual screen readers on target platforms
- User testing: Work with visually impaired players to validate usability
Platform Support
Rummage supports these screen reader platforms:
- Windows: NVDA and JAWS
- macOS: VoiceOver
- Linux: Orca
- Web: ARIA support for browser-based screen readers
Future Improvements
Planned improvements for screen reader support:
- Enhanced contextual descriptions for complex board states
- Custom screen reader modes for different game phases
- Integrated tutorials specific to screen reader users
- Support for braille displays
Best Practices
When implementing new UI components, follow these accessibility best practices:
- Always include an
AccessibleNode
component with appropriate role and label - Ensure all interactive elements are keyboard navigable
- Announce important state changes
- Test with actual screen readers
- Group related elements semantically
Related Documentation
Color Contrast
Control Options
Game UI Testing Guide
This document outlines testing strategies and methodologies for Rummage's game UI system, with a focus on ensuring stability, correctness, and usability across different scenarios.
Table of Contents
- Testing Philosophy
- Unit Testing
- Integration Testing
- Visual Regression Testing
- Performance Testing
- Accessibility Testing
- Automation Framework
- Test Case Organization
Testing Philosophy
The UI testing approach for Rummage follows these core principles:
- Comprehensive Coverage: Test all UI components across different configurations
- Behavior-Driven: Focus on testing functionality from a user perspective
- Automated Where Possible: Leverage automation for regression testing
- Visual Correctness: Ensure visual elements appear as designed
- Performance Aware: Verify UI performs well under different conditions
Unit Testing
Unit tests focus on testing individual UI components in isolation.
Component Testing
Test each UI component separately to verify:
- Correct initialization
- Proper event handling
- State transitions
- Component lifecycle behaviors
Example test for a component:
#![allow(unused)] fn main() { #[test] fn test_hand_zone_initialization() { // Create app with test plugins let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, ui_systems::update_hand_zone); // Setup test state let player_id = PlayerId(1); let test_cards = vec![ Card::new("Test Card 1"), Card::new("Test Card 2"), Card::new("Test Card 3"), ]; // Spawn hand zone with test cards app.world.spawn(( HandZone { player_id, expanded: false }, Node { width: Val::Percent(100.0), height: Val::Px(200.0), ..default() }, )); // Add cards to player's hand let hand = Hand { cards: test_cards, player_id }; app.world.insert_resource(hand); // Run systems app.update(); // Verify hand zone contains correct number of card entities let hand_entity = app.world.query_filtered::<Entity, With<HandZone>>().single(&app.world); let children = app.world.get::<Children>(hand_entity).unwrap(); assert_eq!(children.len(), 3, "Hand zone should contain 3 card entities"); } }
Event Handling Tests
Test that UI components respond correctly to events:
#![allow(unused)] fn main() { #[test] fn test_card_drag_event_handling() { // Create test app let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_event::<CardDragStartEvent>() .add_event::<CardDragEndEvent>() .add_systems(Update, ui_systems::handle_card_drag_events); // Set up test entities let card_entity = app.world.spawn(Card::new("Test Card")).id(); // Trigger drag start event app.world.send_event(CardDragStartEvent { card_entity, cursor_position: Vec2::new(100.0, 100.0), }); // Run systems app.update(); // Verify card is being dragged let dragging = app.world.get::<Dragging>(card_entity).unwrap(); assert!(dragging.active, "Card should be in dragging state"); // Trigger drag end event app.world.send_event(CardDragEndEvent { card_entity, cursor_position: Vec2::new(200.0, 200.0), }); // Run systems app.update(); // Verify card is no longer being dragged assert!(app.world.get::<Dragging>(card_entity).is_none(), "Dragging component should be removed"); } }
Layout Tests
Verify that UI layout components arrange children correctly:
#![allow(unused)] fn main() { #[test] fn test_battlefield_layout() { // Create test app let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, ui_systems::position_battlefield_cards); // Set up battlefield entity with cards let battlefield_entity = app.world.spawn(( BattlefieldZone { player_id: PlayerId(1), organization: BattlefieldOrganization::ByType, }, Node { width: Val::Percent(100.0), height: Val::Percent(100.0), ..default() }, Transform::default(), GlobalTransform::default(), )).id(); // Add some test cards to the battlefield let card_entities = (0..5).map(|i| { let card_entity = app.world.spawn(( Card::new(&format!("Test Card {}", i)), CardType::Creature, Transform::default(), GlobalTransform::default(), Parent(battlefield_entity), )).id(); app.world.entity_mut(battlefield_entity).add_child(card_entity); card_entity }).collect::<Vec<_>>(); // Run systems app.update(); // Verify cards are positioned correctly for (i, card_entity) in card_entities.iter().enumerate() { let transform = app.world.get::<Transform>(*card_entity).unwrap(); // Cards should be arranged in a row with spacing assert_approx_eq!(transform.translation.x, i as f32 * 120.0, 1.0); } } }
Integration Testing
Integration tests verify that multiple UI components work together correctly.
Playmat Integration Tests
Test that all zones in a player's playmat interact correctly:
#![allow(unused)] fn main() { #[test] fn test_card_movement_between_zones() { // Create test app let mut app = App::new(); app.add_plugins(GameUiTestPlugins) .add_systems(Update, ( ui_systems::handle_card_movement, ui_systems::update_zones, )); // Set up test player and playmat let player_id = PlayerId(1); setup_test_playmat(&mut app, player_id); // Get zone entities let hand_entity = app.world.query_filtered::<Entity, With<HandZone>>().single(&app.world); let battlefield_entity = app.world.query_filtered::<Entity, With<BattlefieldZone>>().single(&app.world); // Create test card in hand let card_entity = app.world.spawn(( Card::new("Test Creature"), CardType::Creature, Transform::default(), GlobalTransform::default(), Parent(hand_entity), InZone::Hand, )).id(); app.world.entity_mut(hand_entity).add_child(card_entity); // Simulate playing card from hand to battlefield app.world.send_event(PlayCardEvent { card_entity, source_zone: Zone::Hand, destination_zone: Zone::Battlefield, player_id, }); // Run systems app.update(); // Verify card moved to battlefield let card_parent = app.world.get::<Parent>(card_entity).unwrap(); assert_eq!(card_parent.get(), battlefield_entity, "Card should be in battlefield"); let in_zone = app.world.get::<InZone>(card_entity).unwrap(); assert_eq!(*in_zone, InZone::Battlefield, "Card zone should be Battlefield"); } }
Table Integration Tests
Test the entire table layout with multiple players:
#![allow(unused)] fn main() { #[test] fn test_four_player_table_layout() { // Create test app let mut app = App::new(); app.add_plugins(GameUiTestPlugins) .add_systems(Update, ui_systems::update_table_layout); // Set up game state with 4 players let mut game_state = GameState::default(); game_state.player_count = 4; app.world.insert_resource(game_state); // Set up virtual table let table_entity = app.world.spawn(( VirtualTable, Node { width: Val::Percent(100.0), height: Val::Percent(100.0), ..default() }, )).id(); // Trigger table setup app.world.send_event(SetupTableEvent { player_count: 4 }); // Run systems app.update(); // Verify table has correct structure for 4 players let children = app.world.get::<Children>(table_entity).unwrap(); // Should have 5 children: 4 player playmats + shared area assert_eq!(children.len(), 5, "Table should have 5 main areas for 4 players"); // Verify each player's playmat exists and has correct position let playmat_query = app.world.query_filtered::<(&PlayerPlaymat, &Node), With<Node>>(); assert_eq!(playmat_query.iter(&app.world).count(), 4, "Should have 4 player playmats"); } }
UI Flow Tests
Test complete UI workflows from start to finish:
#![allow(unused)] fn main() { #[test] fn test_cast_spell_targeting_ui_flow() { // Create test app let mut app = App::new(); app.add_plugins(GameUiTestPlugins) .add_systems(Update, ( ui_systems::handle_card_selection, ui_systems::handle_targeting, ui_systems::update_stack_visualization, )); // Set up game with 2 players setup_test_game(&mut app, 2); // Get player hand and opponent battlefield let player_id = PlayerId(1); let opponent_id = PlayerId(2); let hand_entity = get_player_zone_entity(&app, player_id, ZoneType::Hand); let opp_battlefield_entity = get_player_zone_entity(&app, opponent_id, ZoneType::Battlefield); // Add test spell to player's hand let spell_entity = app.world.spawn(( Card::new("Test Lightning Bolt"), CardType::Instant, RequiresTarget { valid_targets: TargetType::CreatureOrPlayer }, Transform::default(), GlobalTransform::default(), Parent(hand_entity), InZone::Hand, )).id(); app.world.entity_mut(hand_entity).add_child(spell_entity); // Add creature to opponent's battlefield let creature_entity = app.world.spawn(( Card::new("Test Creature"), CardType::Creature, Transform::default(), GlobalTransform::default(), Parent(opp_battlefield_entity), InZone::Battlefield, )).id(); app.world.entity_mut(opp_battlefield_entity).add_child(creature_entity); // Simulate spell cast app.world.send_event(CardSelectedEvent { card_entity: spell_entity, player_id, }); app.update(); // Verify targeting mode is active let ui_state = app.world.resource::<UiState>(); assert_eq!(ui_state.mode, UiMode::Targeting, "UI should be in targeting mode"); // Simulate target selection app.world.send_event(TargetSelectedEvent { source_entity: spell_entity, target_entity: creature_entity, player_id, }); app.update(); // Verify spell is on stack let stack_entity = app.world.query_filtered::<Entity, With<StackZone>>().single(&app.world); let stack_children = app.world.get::<Children>(stack_entity).unwrap(); assert!(!stack_children.is_empty(), "Stack should contain the cast spell"); // Verify targeting visualization let targeting = app.world.query_filtered::<&TargetingVisualization, With<TargetingVisualization>>().single(&app.world); assert_eq!(targeting.source, spell_entity); assert_eq!(targeting.target, creature_entity); } }
Visual Regression Testing
Visual tests verify that UI components appear correctly.
Screenshot Comparison Tests
Compare screenshots of UI elements against reference images:
#![allow(unused)] fn main() { #[test] fn test_card_appearance() { // Create test app let mut app = App::new(); app.add_plugins(VisualTestPlugins) .add_systems(Update, ui_systems::render_card); // Set up test card let card = Card::new("Test Card"); card.card_type = CardType::Creature; card.mana_cost = "2R".into(); app.world.spawn(( card, Transform::from_xyz(400.0, 300.0, 0.0), GlobalTransform::default(), )); // Render frame app.update(); // Take screenshot let screenshot = take_screenshot(&app); // Compare with reference image let reference = load_reference_image("card_appearance.png"); let difference = compare_images(&screenshot, &reference); assert!(difference < 0.01, "Card appearance doesn't match reference"); } }
Layout Verification Tests
Verify layout properties of UI elements:
#![allow(unused)] fn main() { #[test] fn test_playmat_layout_proportions() { // Create test app let mut app = App::new(); app.add_plugins(GameUiTestPlugins); // Set up test playmat setup_test_playmat(&mut app, PlayerId(1)); // Run systems app.update(); // Query zone entities let hand_query = app.world.query_filtered::<&Node, With<HandZone>>(); let battlefield_query = app.world.query_filtered::<&Node, With<BattlefieldZone>>(); // Verify hand zone height let hand_node = hand_query.single(&app.world); match hand_node.height { Val::Percent(percent) => { assert!(percent > 15.0 && percent < 25.0, "Hand zone height should be ~20%"); }, _ => panic!("Hand height should be a percentage"), } // Verify battlefield zone height let battlefield_node = battlefield_query.single(&app.world); match battlefield_node.height { Val::Percent(percent) => { assert!(percent > 50.0 && percent < 70.0, "Battlefield height should be ~60%"); }, _ => panic!("Battlefield height should be a percentage"), } } }
Performance Testing
Performance tests verify that the UI performs well under different conditions.
Card Volume Tests
Test UI performance with large numbers of cards:
#![allow(unused)] fn main() { #[test] fn test_battlefield_performance_with_many_cards() { // Create test app let mut app = App::new(); app.add_plugins(GameUiTestPlugins) .add_plugins(DiagnosticsPlugin); // Set up battlefield with many cards let battlefield_entity = app.world.spawn(( BattlefieldZone { player_id: PlayerId(1), organization: BattlefieldOrganization::ByType, }, Node { width: Val::Percent(100.0), height: Val::Percent(100.0), ..default() }, )).id(); // Add 100 test cards to the battlefield for i in 0..100 { let card_entity = app.world.spawn(( Card::new(&format!("Test Card {}", i)), CardType::Creature, Transform::default(), GlobalTransform::default(), )).id(); app.world.entity_mut(battlefield_entity).add_child(card_entity); } // Run performance test let mut frame_times = Vec::new(); for _ in 0..100 { let start = std::time::Instant::now(); app.update(); frame_times.push(start.elapsed()); } // Calculate average frame time let avg_frame_time = frame_times.iter().sum::<std::time::Duration>() / frame_times.len() as u32; // Average frame time should be under 16ms (60fps) assert!(avg_frame_time.as_millis() < 16, "UI is not performing well with many cards"); } }
Animation Performance Tests
Test performance of UI animations:
#![allow(unused)] fn main() { #[test] fn test_card_draw_animation_performance() { // Create test app let mut app = App::new(); app.add_plugins(GameUiTestPlugins) .add_plugins(DiagnosticsPlugin); // Set up player zones setup_test_playmat(&mut app, PlayerId(1)); // Get zone entities let library_entity = app.world.query_filtered::<Entity, With<LibraryZone>>().single(&app.world); let hand_entity = app.world.query_filtered::<Entity, With<HandZone>>().single(&app.world); // Set up library with cards for i in 0..50 { let card_entity = app.world.spawn(( Card::new(&format!("Library Card {}", i)), Transform::default(), GlobalTransform::default(), Parent(library_entity), InZone::Library, )).id(); app.world.entity_mut(library_entity).add_child(card_entity); } // Measure performance while drawing multiple cards let mut frame_times = Vec::new(); for i in 0..7 { // Draw a card let card_entity = app.world.query_filtered::<Entity, (With<Card>, With<InZone>)>() .iter(&app.world) .next() .unwrap(); app.world.send_event(DrawCardEvent { player_id: PlayerId(1), card_entity, }); // Run update and measure frame time let start = std::time::Instant::now(); app.update(); frame_times.push(start.elapsed()); } // Calculate average and maximum frame time let avg_frame_time = frame_times.iter().sum::<std::time::Duration>() / frame_times.len() as u32; let max_frame_time = frame_times.iter().max().unwrap(); // Performance assertions assert!(avg_frame_time.as_millis() < 16, "Card draw animation average performance is too slow"); assert!(max_frame_time.as_millis() < 32, "Card draw animation maximum frame time is too slow"); } }
Accessibility Testing
Tests to verify UI accessibility features.
Color Blind Mode Tests
Test that UI is usable in color blind modes:
#![allow(unused)] fn main() { #[test] fn test_color_blind_mode_card_distinction() { // Create test app let mut app = App::new(); app.add_plugins(GameUiTestPlugins); // Set accessibility settings to deuteranopia mode let mut settings = AccessibilitySettings::default(); settings.color_blind_mode = ColorBlindMode::Deuteranopia; app.world.insert_resource(settings); // Set up cards of different colors let red_card = app.world.spawn(( Card::new("Red Card"), CardColor::Red, Transform::from_xyz(100.0, 100.0, 0.0), GlobalTransform::default(), )).id(); let green_card = app.world.spawn(( Card::new("Green Card"), CardColor::Green, Transform::from_xyz(300.0, 100.0, 0.0), GlobalTransform::default(), )).id(); // Run systems to update card appearance app.update(); // Get card visual components let red_card_appearance = app.world.get::<CardAppearance>(red_card).unwrap(); let green_card_appearance = app.world.get::<CardAppearance>(green_card).unwrap(); // In deuteranopia mode, these colors should have distinct patterns or indicators // rather than relying solely on red/green color difference assert_ne!( red_card_appearance.pattern_type, green_card_appearance.pattern_type, "Cards should have distinct patterns in color blind mode" ); } }
Keyboard Navigation Tests
Test keyboard navigation through UI elements:
#![allow(unused)] fn main() { #[test] fn test_keyboard_navigation() { // Create test app let mut app = App::new(); app.add_plugins(GameUiTestPlugins) .add_systems(Update, ui_systems::handle_keyboard_input); // Set up test game setup_test_game(&mut app, 2); // Initialize UI focus state app.world.insert_resource(UiFocus { current_focus: None, navigation_mode: true, }); // Simulate keyboard input to start navigation app.world.send_event(KeyboardInput { key: KeyCode::Tab, state: ButtonState::Pressed, }); app.update(); // Verify navigation mode is active and something is focused let ui_focus = app.world.resource::<UiFocus>(); assert!(ui_focus.navigation_mode, "Keyboard navigation mode should be active"); assert!(ui_focus.current_focus.is_some(), "An element should be focused"); // Test navigation between elements let first_focus = ui_focus.current_focus; // Simulate arrow key press app.world.send_event(KeyboardInput { key: KeyCode::ArrowRight, state: ButtonState::Pressed, }); app.update(); // Verify focus changed let ui_focus = app.world.resource::<UiFocus>(); assert_ne!(ui_focus.current_focus, first_focus, "Focus should have moved to a new element"); } }
Automation Framework
The testing framework uses several helper functions and utilities:
#![allow(unused)] fn main() { /// Set up a test playmat with all zones for a player fn setup_test_playmat(app: &mut App, player_id: PlayerId) -> Entity { // Create player entity let player_entity = app.world.spawn(( Player { id: player_id, name: format!("Test Player {}", player_id.0) }, )).id(); // Spawn playmat let playmat_entity = app.world.spawn(( PlayerPlaymat { player_id }, Node { width: Val::Percent(100.0), height: Val::Percent(100.0), flex_direction: FlexDirection::Column, ..default() }, )).id(); // Spawn hand zone let hand_entity = app.world.spawn(( HandZone { player_id, expanded: false }, Node { width: Val::Percent(100.0), height: Val::Percent(20.0), ..default() }, Parent(playmat_entity), )).id(); app.world.entity_mut(playmat_entity).add_child(hand_entity); // Spawn battlefield zone let battlefield_entity = app.world.spawn(( BattlefieldZone { player_id, organization: BattlefieldOrganization::ByType, }, Node { width: Val::Percent(100.0), height: Val::Percent(60.0), ..default() }, Parent(playmat_entity), )).id(); app.world.entity_mut(playmat_entity).add_child(battlefield_entity); // Add other zones (library, graveyard, etc.) // ... playmat_entity } /// Set up a test game with the specified number of players fn setup_test_game(app: &mut App, player_count: usize) { // Create game state let mut game_state = GameState::default(); game_state.player_count = player_count; app.world.insert_resource(game_state); // Create table let table_entity = app.world.spawn(( VirtualTable, Node { width: Val::Percent(100.0), height: Val::Percent(100.0), ..default() }, )).id(); // Add playmats for each player for i in 0..player_count { let player_id = PlayerId(i as u32 + 1); let playmat_entity = setup_test_playmat(app, player_id); app.world.entity_mut(table_entity).add_child(playmat_entity); } // Add shared zones setup_shared_zones(app, table_entity); } /// Helper function to take screenshot in tests fn take_screenshot(app: &App) -> Image { // Implementation would depend on rendering backend // This is a simplified example let mut image = Image::default(); // Get main camera let camera_entity = app.world.query_filtered::<Entity, With<Camera>>().single(&app.world); // Render to texture // (Implementation details would depend on Bevy's rendering API) image } }
Test Case Organization
Test cases are organized into test suites that focus on specific aspects of the UI:
- Layout Tests: Verify correct positioning and sizing of UI elements
- Interaction Tests: Verify user interactions work correctly
- Visual Tests: Verify visual appearance matches expectations
- Performance Tests: Verify UI performance under different conditions
- Accessibility Tests: Verify accessibility features work correctly
- Integration Tests: Verify different UI components work together
Each test suite is implemented as a separate module, with common helpers in a shared module:
#![allow(unused)] fn main() { mod layout_tests { use super::*; #[test] fn test_two_player_table_layout() { /* ... */ } #[test] fn test_four_player_table_layout() { /* ... */ } // More layout tests... } mod interaction_tests { use super::*; #[test] fn test_card_selection() { /* ... */ } #[test] fn test_drag_and_drop() { /* ... */ } // More interaction tests... } // More test modules... }
When running tests, use tags to run specific categories:
cargo test ui::layout # Run layout tests
cargo test ui::visual # Run visual tests
cargo test ui::all # Run all UI tests
UI Component Testing
This guide covers how to test UI components in the Rummage game engine.
Overview
Testing UI components is essential to ensure that the game's interface remains consistent, responsive, and intuitive. In Rummage, we use a combination of unit tests, integration tests, and visual regression tests to validate our UI components.
Testing Approach
Our UI component testing strategy follows these principles:
- Component Isolation: Test individual UI components in isolation
- Behavior Verification: Verify that components respond correctly to user interactions
- Layout Validation: Ensure components maintain proper layout across different screen sizes
- Integration Testing: Test how components interact with each other
- Visual Consistency: Maintain visual appearance through visual regression testing
Test Structure
UI component tests are organized as follows:
src/
tests/
ui/
components/
buttons_test.rs
cards_test.rs
menus_test.rs
interactions/
drag_drop_test.rs
selection_test.rs
Testing UI Components with Bevy
Bevy's entity-component-system architecture requires a specific approach to UI testing.
Component Unit Tests
For component unit tests, we focus on testing the component's properties and behaviors:
#![allow(unused)] fn main() { #[test] fn test_button_component() { // Create a test app with required plugins let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(UiPlugin); // Spawn a button entity let button_entity = app.world.spawn(( ButtonBundle { style: Style { width: Val::Px(150.0), height: Val::Px(50.0), ..default() }, background_color: Color::rgb(0.15, 0.15, 0.15).into(), ..default() }, Button {}, )).id(); // Test button properties let button_query = app.world.query::<(&Button, &Style)>(); let (_, style) = button_query.get(&app.world, button_entity).unwrap(); assert_eq!(style.width, Val::Px(150.0)); assert_eq!(style.height, Val::Px(50.0)); } }
Interaction Tests
Interaction tests validate how components respond to user input:
#![allow(unused)] fn main() { #[test] fn test_button_click() { // Create a test app with required plugins let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(UiPlugin) .add_event::<ButtonClickEvent>(); // Add a system to handle button interaction app.add_systems(Update, button_click_system); // Spawn a button entity let button_entity = app.world.spawn(( ButtonBundle { ... }, Button {}, )).id(); // Simulate interaction with the button app.world.entity_mut(button_entity) .insert(Interaction::Clicked); // Run systems app.update(); // Check if appropriate events were generated let button_events = app.world.resource::<Events<ButtonClickEvent>>(); let mut reader = button_events.get_reader(); let events: Vec<_> = reader.read(button_events).collect(); assert_eq!(events.len(), 1); } }
Layout Tests
Layout tests verify that UI components maintain proper layout:
#![allow(unused)] fn main() { #[test] fn test_responsive_layout() { // Create a test app let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(UiPlugin); // Create a responsive UI component let panel_entity = app.world.spawn(( NodeBundle { style: Style { width: Val::Percent(100.0), // Other style properties ..default() }, ..default() }, )).id(); // Test different window sizes for window_width in [800.0, 1200.0, 1600.0] { // Update window size app.world.resource_mut::<Windows>().primary_mut().set_resolution(window_width, 900.0); // Run layout systems app.update(); // Verify layout adaptation // ... } } }
Visual Regression Testing
For visual regression testing, see the Visual Regression guide.
Integration with Game Logic
UI component tests should also verify correct integration with game logic when applicable:
#![allow(unused)] fn main() { #[test] fn test_card_drag_affects_game_state() { // Setup test app with UI and game state let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(UiPlugin) .add_plugin(GameStatePlugin); // Setup game state // ... // Test drag interaction // ... // Verify game state was updated correctly // ... } }
Testing Accessibility
UI component tests should also verify accessibility requirements:
#![allow(unused)] fn main() { #[test] fn test_button_accessibility() { // Create a test app let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(UiPlugin) .add_plugin(AccessibilityPlugin); // Create a button with accessibility attributes let button_entity = app.world.spawn(( ButtonBundle { ... }, Button {}, AccessibilityNode { label: "Submit".into(), role: AccessibilityRole::Button, ..default() }, )).id(); // Verify accessibility properties let accessibility_query = app.world.query::<&AccessibilityNode>(); let accessibility = accessibility_query.get(&app.world, button_entity).unwrap(); assert_eq!(accessibility.label, "Submit"); assert_eq!(accessibility.role, AccessibilityRole::Button); } }
Best Practices
- Test Real Scenarios: Focus on testing real user scenarios rather than implementation details
- Isolate UI Logic: Keep UI logic separate from game logic to make testing easier
- Test Different Screen Sizes: Verify that UI works across different screen resolutions
- Test Accessibility: Ensure UI components meet accessibility standards
- Use Visual Regression Tests: Complement code tests with visual regression tests
Related Documentation
Visual Regression Testing
This guide covers visual regression testing for the Rummage game's UI components, ensuring visual consistency across updates.
Introduction to Visual Regression Testing
Visual regression testing is a technique that ensures UI components maintain their visual appearance across code changes. It works by capturing screenshots of UI components and comparing them against baseline images to detect unexpected visual changes.
In Rummage, visual regression testing is essential for maintaining a consistent, polished user interface across the diverse board states that can occur in a Magic: The Gathering game.
Visual Testing Framework
Rummage uses a custom visual testing framework built on top of Bevy's testing capabilities, with these key components:
- Snapshot Capture: Renders UI components to off-screen buffers and captures their visual state
- Image Comparison: Compares captured images against baseline images pixel-by-pixel
- Difference Visualization: Generates difference images highlighting visual changes
- Test Reporting: Provides detailed reports on visual changes
Setting Up Visual Regression Tests
Visual regression tests are located in the tests/visual
directory and follow this structure:
tests/
visual/
components/
card_test.rs
menu_test.rs
screens/
battlefield_test.rs
hand_test.rs
reference_images/
card_normal.png
card_tapped.png
Writing Visual Regression Tests
Here's how to write a visual regression test:
#![allow(unused)] fn main() { #[test] fn test_card_visual_appearance() { // Set up a test app with required plugins let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(RenderPlugin) .add_plugin(UiPlugin) .add_plugin(VisualTestPlugin); // Create a test card entity let card_entity = app.world.spawn(( SpriteBundle { sprite: Sprite { color: Color::rgb(1.0, 1.0, 1.0), custom_size: Some(Vec2::new(63.0, 88.0)), ..default() }, transform: Transform::from_translation(Vec3::new(0.0, 0.0, 0.0)), ..default() }, Card { name: "Test Card".to_string(), // Other card properties }, CardVisual { state: CardVisualState::Normal, }, )).id(); // Set up camera app.world.spawn(Camera2dBundle::default()); // Capture and compare visual state let visual_test = VisualTest::new(&mut app) .capture_entity(card_entity) .compare_to_reference("card_normal.png") .with_tolerance(0.01); // 1% pixel difference tolerance // Assert visual consistency assert!(visual_test.is_visually_consistent()); } }
Testing Different Visual States
It's important to test different visual states of UI components:
#![allow(unused)] fn main() { #[test] fn test_card_visual_states() { // Set up test app and card entity // ... // Test normal state let visual_test = VisualTest::new(&mut app) .capture_entity(card_entity) .compare_to_reference("card_normal.png"); assert!(visual_test.is_visually_consistent()); // Change to tapped state app.world.entity_mut(card_entity).get_mut::<CardVisual>().unwrap().state = CardVisualState::Tapped; app.world.entity_mut(card_entity).get_mut::<Transform>().unwrap().rotation = Quat::from_rotation_z(std::f32::consts::FRAC_PI_2); // Update systems to apply visual changes app.update(); // Test tapped state let visual_test = VisualTest::new(&mut app) .capture_entity(card_entity) .compare_to_reference("card_tapped.png"); assert!(visual_test.is_visually_consistent()); } }
Testing Responsive Layouts
Visual tests should also verify appearance across different screen sizes:
#![allow(unused)] fn main() { #[test] fn test_responsive_menu_layout() { // Set up test app // ... // Test different screen sizes for (width, height, suffix) in [ (1920, 1080, "full_hd"), (1280, 720, "hd"), (800, 600, "low_res"), ] { // Set window size app.world.resource_mut::<Windows>().primary_mut().set_resolution( width as f32, height as f32 ); // Update systems to apply layout changes app.update(); // Capture and compare let reference_name = format!("menu_{}.png", suffix); let visual_test = VisualTest::new(&mut app) .capture_full_screen() .compare_to_reference(&reference_name); assert!(visual_test.is_visually_consistent()); } } }
Generating Baseline Images
When developing new UI components, you'll need to generate baseline images:
#![allow(unused)] fn main() { #[test] fn generate_baseline_for_new_component() { // Set up test app and component // ... // Generate baseline image instead of comparing let visual_test = VisualTest::new(&mut app) .capture_entity(component_entity) .generate_baseline("new_component.png"); // Verify the baseline was created assert!(visual_test.baseline_generated()); } }
Handling Animations
For components with animations, capture key frames:
#![allow(unused)] fn main() { #[test] fn test_card_draw_animation() { // Set up test app // ... // Start animation app.world.spawn(DrawCardEvent { ... }); // Capture key frames of the animation let frames = [0, 5, 10, 15, 20]; for frame in frames { // Advance animation to specific frame for _ in 0..frame { app.update(); } // Capture and compare let reference_name = format!("card_draw_frame_{}.png", frame); let visual_test = VisualTest::new(&mut app) .capture_entity(card_entity) .compare_to_reference(&reference_name); assert!(visual_test.is_visually_consistent()); } } }
Configuring Test Thresholds
Adjust tolerance thresholds for different components:
#![allow(unused)] fn main() { // Text rendering may vary slightly across platforms let text_test = VisualTest::new(&mut app) .capture_entity(text_entity) .compare_to_reference("card_text.png") .with_tolerance(0.03); // 3% tolerance // Card art should be pixel-perfect let art_test = VisualTest::new(&mut app) .capture_entity(art_entity) .compare_to_reference("card_art.png") .with_tolerance(0.0); // 0% tolerance }
Reporting and Debugging
When visual tests fail, detailed reports help identify the issue:
#![allow(unused)] fn main() { let result = visual_test.run(); if !result.passed { // Generate detailed report result.generate_report("visual_test_report"); // Output difference statistics println!("Pixel difference: {}%", result.difference_percentage); println!("Most different area: {:?}", result.most_different_region); // Save difference image result.save_difference_image("difference.png"); } }
Integration with CI/CD
Visual regression tests can be integrated into CI/CD pipelines:
# .github/workflows/visual-tests.yml
visual-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libxcb-shape0-dev libxcb-xfixes0-dev
- name: Run visual regression tests
run: cargo test --package rummage --test visual_tests
- name: Upload difference images on failure
if: failure()
uses: actions/upload-artifact@v2
with:
name: visual-diff-images
path: target/visual-test-output/
Best Practices
- Keep baseline images in version control to track intentional visual changes
- Test across different themes (light/dark mode)
- Use appropriate tolerances for different components
- Set up CI/CD integration to catch visual regressions early
- Test across different screen resolutions to ensure responsive design works
- Include visual tests in your development workflow
Troubleshooting
Common Issues
- Platform differences: Different operating systems may render text slightly differently
- Resolution variations: High-DPI displays may produce different pixel counts
- Color profile differences: Different monitors may display colors differently
Solutions
- Use tolerance thresholds appropriate to the component
- Normalize rendering environment in CI/CD
- Implement platform-specific baseline images when necessary
Related Documentation
Card Systems Integration
This document provides a detailed explanation of how the Game UI system integrates with the Card Systems in Rummage.
Table of Contents
- Overview
- Visualization Pipeline
- Interaction Translation
- Special Card Rendering
- Implementation Examples
Overview
The Game UI and Card Systems modules work together to create a seamless player experience. The Card Systems module provides the data model and game logic for cards, while the Game UI module translates this data into visual elements and player interactions.
This integration follows these key principles:
- Separation of Concerns: Card data and logic remain in the Card Systems module, while visualization and interaction are handled by the Game UI
- Event-Driven Communication: Changes in card state trigger events that the UI responds to
- Bidirectional Flow: Player interactions with the UI generate events that affect the underlying card systems
- Visual Consistency: Card rendering maintains a consistent visual language across different card types and states
Visualization Pipeline
The visualization pipeline transforms card data from the Card Systems module into visual elements in the Game UI:
Card Entity Mapping
Each card entity in the game engine has a corresponding visual entity in the UI:
#![allow(unused)] fn main() { fn create_card_visuals( mut commands: Commands, card_query: Query<(Entity, &Card, &CardName, &CardType), Added<Card>>, asset_server: Res<AssetServer>, ) { for (entity, card, name, card_type) in &card_query { // Create visual entity linked to the card entity let visual_entity = commands.spawn(( // Visual components CardVisual { card_entity: entity }, Sprite { custom_size: Some(Vec2::new(CARD_WIDTH, CARD_HEIGHT)), ..default() }, SpriteSheetBundle { texture: asset_server.load(&get_card_texture_path(card)), ..default() }, // UI interaction components Interactable, Draggable, // Transform for positioning Transform::default(), GlobalTransform::default(), )).id(); // Link the card entity to its visual representation commands.entity(entity).insert(VisualRepresentation(visual_entity)); } } }
State Change Propagation
When card state changes in the game engine, these changes are reflected in the UI:
#![allow(unused)] fn main() { fn update_card_visuals( mut commands: Commands, card_query: Query<(Entity, &VisualRepresentation, &ZoneType, Option<&Tapped>), Changed<ZoneType>>, mut visual_query: Query<(&mut Transform, &mut Visibility)>, ) { for (card_entity, visual_rep, zone, tapped) in &card_query { if let Ok((mut transform, mut visibility)) = visual_query.get_mut(visual_rep.0) { // Update position based on zone match zone { ZoneType::Battlefield => { visibility.is_visible = true; // Position on battlefield }, ZoneType::Hand => { visibility.is_visible = true; // Position in hand }, ZoneType::Library | ZoneType::Graveyard | ZoneType::Exile => { // Position in appropriate zone }, _ => { visibility.is_visible = false; } } // Update rotation based on tapped state if tapped.is_some() { transform.rotation = Quat::from_rotation_z(std::f32::consts::FRAC_PI_2); } else { transform.rotation = Quat::default(); } } } } }
Visual Effects
The UI adds visual effects to represent game actions:
#![allow(unused)] fn main() { fn add_cast_visual_effect( mut commands: Commands, mut cast_events: EventReader<CastSpellEvent>, card_query: Query<&VisualRepresentation>, ) { for event in cast_events.iter() { if let Ok(visual_rep) = card_query.get(event.card) { // Add casting visual effect to the card commands.entity(visual_rep.0).insert(CastingEffect { duration: 0.5, elapsed: 0.0, }); } } } }
Interaction Translation
The UI translates player interactions into game actions:
Drag and Drop
#![allow(unused)] fn main() { fn handle_card_drag_end( mut commands: Commands, mut drag_events: EventReader<DragEndEvent>, card_visuals: Query<&CardVisual>, zones: Query<(Entity, &ZoneType, &DropTarget)>, mut zone_transfer_events: EventWriter<ZoneTransferEvent>, ) { for event in drag_events.iter() { if let Ok(card_visual) = card_visuals.get(event.entity) { // Find the zone the card was dropped on for (zone_entity, zone_type, drop_target) in &zones { if drop_target.contains(event.position) { // Send zone transfer event zone_transfer_events.send(ZoneTransferEvent { card: card_visual.card_entity, target_zone: zone_entity, target_zone_type: *zone_type, }); break; } } } } } }
Card Selection
#![allow(unused)] fn main() { fn handle_card_selection( mut commands: Commands, mut click_events: EventReader<ClickEvent>, card_visuals: Query<&CardVisual>, mut selection_events: EventWriter<CardSelectionEvent>, ) { for event in click_events.iter() { if let Ok(card_visual) = card_visuals.get(event.entity) { // Send selection event selection_events.send(CardSelectionEvent { card: card_visual.card_entity, selection_type: if event.button == MouseButton::Left { SelectionType::Primary } else { SelectionType::Secondary }, }); } } } }
Context Menus
#![allow(unused)] fn main() { fn show_card_context_menu( mut commands: Commands, mut right_click_events: EventReader<RightClickEvent>, card_visuals: Query<&CardVisual>, cards: Query<(&Card, &CardType, &ZoneType)>, ) { for event in right_click_events.iter() { if let Ok(card_visual) = card_visuals.get(event.entity) { if let Ok((card, card_type, zone)) = cards.get(card_visual.card_entity) { // Create context menu based on card type and zone let menu_entity = commands.spawn(( ContextMenu { card: card_visual.card_entity, position: event.screen_position, }, // UI components for the menu )).id(); // Add appropriate actions based on card type and zone add_context_menu_actions( &mut commands, menu_entity, card_visual.card_entity, card_type, zone ); } } } } }
Special Card Rendering
Some cards require special rendering treatment:
Modal Cards
#![allow(unused)] fn main() { fn show_modal_card_options( mut commands: Commands, mut cast_events: EventReader<CastModalSpellEvent>, cards: Query<&ModalOptions>, ) { for event in cast_events.iter() { if let Ok(modal_options) = cards.get(event.card) { // Create modal selection UI let modal_ui = commands.spawn(( ModalSelectionUI { card: event.card, options: modal_options.options.clone(), }, // UI components )).id(); // Add option buttons for (i, option) in modal_options.options.iter().enumerate() { commands.spawn(( ModalOptionButton { parent: modal_ui, option_index: i, }, // UI components // Text component with option description )); } } } } }
Split Cards
#![allow(unused)] fn main() { fn render_split_card( mut commands: Commands, split_cards: Query<(Entity, &SplitCard, &VisualRepresentation)>, mut textures: ResMut<Assets<TextureAtlas>>, asset_server: Res<AssetServer>, ) { for (entity, split_card, visual_rep) in &split_cards { // Load both halves of the split card let left_texture = asset_server.load(&split_card.left_half_texture); let right_texture = asset_server.load(&split_card.right_half_texture); // Create a special sprite that combines both halves commands.entity(visual_rep.0).insert(SplitCardVisual { left_half: left_texture, right_half: right_texture, }); } } }
Implementation Examples
Card Hover Preview
#![allow(unused)] fn main() { fn card_hover_preview( mut commands: Commands, hover_events: EventReader<HoverEvent>, card_visuals: Query<&CardVisual>, cards: Query<(&Card, &CardName)>, mut preview_query: Query<Entity, With<CardPreview>>, ) { // Remove existing previews for preview_entity in &preview_query { commands.entity(preview_entity).despawn_recursive(); } // Create new preview for hovered card for event in hover_events.iter() { if let Ok(card_visual) = card_visuals.get(event.entity) { if let Ok((card, name)) = cards.get(card_visual.card_entity) { commands.spawn(( CardPreview, // Large card image ImageBundle { image: UiImage::new(asset_server.load(&get_card_preview_texture(card))), style: Style { position_type: PositionType::Absolute, position: UiRect { left: Val::Px(event.screen_position.x + 20.0), top: Val::Px(event.screen_position.y - 200.0), ..default() }, size: Size::new(Val::Px(240.0), Val::Px(336.0)), ..default() }, ..default() }, )); } } } } }
Battlefield Layout
#![allow(unused)] fn main() { fn organize_battlefield_cards( mut commands: Commands, players: Query<(Entity, &Player)>, battlefield: Query<Entity, With<BattlefieldZone>>, cards_in_battlefield: Query<(Entity, &VisualRepresentation), (With<Card>, With<ZoneType>)>, mut card_visuals: Query<&mut Transform>, ) { if let Ok(battlefield_entity) = battlefield.get_single() { // Group cards by controller let mut player_cards: HashMap<Entity, Vec<Entity>> = HashMap::new(); for (card_entity, visual_rep) in &cards_in_battlefield { if let Some(controller) = get_card_controller(card_entity) { player_cards.entry(controller) .or_insert_with(Vec::new) .push(visual_rep.0); } } // Position each player's cards in their battlefield section for (player_entity, player) in &players { if let Some(visual_entities) = player_cards.get(&player_entity) { let player_section = get_player_battlefield_section(player_entity, &players); for (i, &visual_entity) in visual_entities.iter().enumerate() { if let Ok(mut transform) = card_visuals.get_mut(visual_entity) { // Calculate position within player's section let row = i / CARDS_PER_ROW; let col = i % CARDS_PER_ROW; transform.translation = Vec3::new( player_section.min.x + col as f32 * (CARD_WIDTH + CARD_SPACING), player_section.min.y + row as f32 * (CARD_HEIGHT + CARD_SPACING), 0.0, ); } } } } } } }
These examples demonstrate the tight integration between the Game UI and Card Systems modules, showing how they work together to create a cohesive player experience while maintaining a clean separation of concerns.
For more information on the Card Systems module, see the Card Systems documentation.
Network Integration
Networking Documentation
This section outlines the implementation of multiplayer functionality for the Rummage MTG Commander game engine using bevy_replicon.
Table of Contents
Overview
The networking system enables multiplayer gameplay with features like state synchronization, lobby management, and rollback mechanisms for handling network disruptions. The implementation is built on bevy_replicon, providing a robust foundation for networked gameplay.
For a comprehensive overview, see the Core Architecture Overview.
Key Components
The networking system consists of the following major components:
Core Networking
- Architecture Overview - Introduction to networking architecture and concepts
- Implementation Details - Detailed implementation guidelines and code structure
- Protocol Specification - Networking protocol, message formats, and synchronization
- Multiplayer Overview - High-level overview of multiplayer functionality
- RNG Synchronization - Managing random number generation across network boundaries
Lobby System
- Lobby Index - Overview of the lobby system
- UI Components - Documentation for user interface components
- Backend - Server-side implementation details
- Chat System - Chat functionality documentation
- Deck Viewer - Deck viewing and management
Gameplay Networking
- Departure Handling - Handling player disconnections and departures
- State Management - Game state synchronization
- Rollback System - State recovery after network disruptions
- Replicon Rollback Integration - Integrating bevy_replicon with RNG state for rollbacks
- Synchronization - Methods for keeping game state in sync
Testing
- Testing Overview - General testing approach
- Advanced Techniques - Advanced testing techniques
- RNG Synchronization Tests - Testing RNG determinism in multiplayer
- Replicon RNG Tests - Testing bevy_replicon integration with RNG state
- Integration Testing - Integration testing methodologies
- Security Testing - Security-focused testing
Security
- Authentication - User authentication and authorization
- Anti-Cheat - Preventing and detecting cheating
- Hidden Information - Managing hidden game information
Implementation Status
This documentation represents the design and implementation of the networking system. Components are marked as follows:
Component | Status | Description |
---|---|---|
Core Network Architecture | ✅ | Basic network architecture using bevy_replicon |
Client-Server Communication | ✅ | Basic client-server messaging |
Lobby System | 🔄 | System for creating and joining game lobbies |
Game State Synchronization | 🔄 | Synchronizing game state across the network |
RNG Synchronization | ✅ | Maintaining consistent random number generation |
Rollback System | ✅ | Recovery from network disruptions |
Replicon Integration | ✅ | Integration with bevy_replicon for entity replication |
Auth System | ⚠️ | Player authentication and authorization |
Anti-Cheat | ⚠️ | Measures to prevent cheating |
Hidden Information | 🔄 | Managing information that should be hidden from certain players |
Chat System | ⚠️ | In-game communication |
Spectator Mode | ⚠️ | Support for non-player observers |
Legend:
- ✅ Implemented and tested
- 🔄 In progress
- ⚠️ Planned but not yet implemented
Recent Updates
Recent updates to the networking documentation include:
- Replicon Integration: Added detailed documentation for integrating bevy_replicon with our RNG state management system
- Rollback Mechanism: Enhanced documentation of rollback mechanisms for handling network disruptions
- Test Framework: Expanded testing documentation with new test cases for RNG synchronization
- Performance Considerations: Added guidance on optimizing network performance
Getting Started
To begin working on the networking implementation:
- Review the Core Architecture Overview
- Understand the Implementation Details
- Set up a local development environment with bevy_replicon
- Start with the basic client-server connectivity
- Incrementally add features following the implementation plan
Future Enhancements
In future versions, we plan to enhance the networking implementation with:
- Spectator Mode: Allow non-players to watch games in progress
- Replay System: Record and replay games for analysis or sharing
- Tournament Support: Special features for organizing and running tournaments
- Cross-Platform Play: Ensure compatibility across different platforms
- Advanced Anti-Cheat: More sophisticated measures to prevent cheating
- Voice Chat Integration: In-game communication between players
This documentation will evolve as the networking implementation progresses. Check back regularly for updates and additional details.
Networking Implementation with bevy_replicon
This document outlines the architecture and implementation details for adding multiplayer networking support to our MTG Commander game engine using bevy_replicon
.
Table of Contents
- Overview
- Setup and Dependencies
- Architecture
- Server Implementation
- Client Implementation
- Replication Strategy
- Game State Synchronization
- Networking Events
- Security Considerations
- Testing and Debugging
Overview
bevy_replicon
is a high-level networking library built on top of bevy_renet
that provides a client-server replication system for Bevy. It handles entity replication, RPC (Remote Procedure Calls), and event synchronization between the server and connected clients.
Our implementation will focus on creating a robust, secure, and efficient networking layer that supports the complex state management required for MTG Commander games while maintaining the game rules integrity.
Setup and Dependencies
Add the following dependencies to your Cargo.toml
file:
[dependencies]
# Existing dependencies...
bevy_replicon = "0.15.0"
bevy_quinnet = { version = "0.6.0", optional = true }
serde = { version = "1.0", features = ["derive"] }
bincode = "1.3"
Architecture
Our networking architecture follows a client-server model:
- Server: Maintains authoritative game state, handles game logic, and broadcasts state changes to clients
- Clients: Connect to the server, send player actions, and render game state received from the server
Key Components:
- NetworkingPlugin: Initializes all networking systems and components
- ServerPlugin: Handles server-specific logic when running as a server
- ClientPlugin: Handles client-specific logic when running as a client
- ReplicationSet: Defines which components should be replicated from server to clients
- NetworkedActions: Server-validated actions that clients can request
Server Implementation
The server is the authoritative source of truth for game state and handles:
- Game session creation and management
- Player connections and authentication
- Processing game actions and maintaining game rules
- Broadcasting state updates to connected clients
#![allow(unused)] fn main() { // src/networking/server.rs use bevy::prelude::*; use bevy_replicon::prelude::*; use crate::game_engine::GameAction; pub struct ServerPlugin; impl Plugin for ServerPlugin { fn build(&self, app: &mut App) { app .add_plugins(bevy_replicon::prelude::ServerPlugin::default()) .add_systems(Startup, setup_server) .add_systems(Update, ( handle_player_connections, process_action_requests, validate_game_state, broadcast_game_events, )); } } fn setup_server(mut commands: Commands) { // Initialize server resources commands.insert_resource(GameServer::new()); // Add server-specific systems and resources // ... } fn handle_player_connections( mut commands: Commands, mut server: ResMut<Server>, mut connection_events: EventReader<ConnectionEvent>, mut clients: ResMut<Clients>, ) { // Handle new player connections and disconnections // ... } fn process_action_requests( mut commands: Commands, mut server: ResMut<Server>, mut action_requests: EventReader<ClientActionRequest>, game_state: Res<GameState>, ) { // Process and validate client action requests // ... } }
Client Implementation
The client is responsible for:
- Connecting to the server
- Sending player inputs and action requests
- Rendering the replicated game state
- Providing feedback for connection status
#![allow(unused)] fn main() { // src/networking/client.rs use bevy::prelude::*; use bevy_replicon::prelude::*; use crate::game_engine::GameAction; pub struct ClientPlugin; impl Plugin for ClientPlugin { fn build(&self, app: &mut App) { app .add_plugins(bevy_replicon::prelude::ClientPlugin::default()) .add_systems(Startup, setup_client) .add_systems(Update, ( handle_connection_status, send_player_actions, apply_server_updates, )); } } fn setup_client(mut commands: Commands) { // Initialize client resources commands.insert_resource(GameClient::new()); // Add client-specific systems and resources // ... } fn handle_connection_status( mut connection_events: EventReader<ConnectionEvent>, mut client_state: ResMut<ClientState>, ) { // Update UI based on connection status // ... } fn send_player_actions( mut client: ResMut<Client>, mut action_queue: ResMut<ActionQueue>, input: Res<Input<KeyCode>>, ) { // Send player actions to the server // ... } }
Replication Strategy
We need to carefully consider which components should be replicated and how to maintain game state integrity.
Server-to-Client Replication:
#![allow(unused)] fn main() { // src/networking/replication.rs use bevy::prelude::*; use bevy_replicon::prelude::*; use crate::card::Card; use crate::player::Player; use crate::game_engine::{GameState, Phase, PrioritySystem}; pub fn register_replication_components(app: &mut App) { app // Register components that should be replicated .replicate::<Card>() .replicate::<Player>() .replicate::<GameState>() .replicate::<Phase>() .replicate::<PrioritySystem>() // Register custom events for replication .replicate_event::<GameAction>(); } #[derive(Component, Serialize, Deserialize, Default)] pub struct NetworkedEntity; #[derive(Component, Serialize, Deserialize)] pub struct OwnerOnly { pub player_id: u64, } }
Game State Synchronization
MTG requires careful synchronization of complex game states:
Card Visibility and Privacy:
#![allow(unused)] fn main() { // Example of handling card visibility for networked games // src/networking/visibility.rs use bevy::prelude::*; use bevy_replicon::prelude::*; use crate::card::Card; use crate::game_engine::zones::{Zone, ZoneType}; // Components for visibility control #[derive(Component)] pub struct VisibleTo { pub player_ids: Vec<u64>, } // System to update card visibility based on game rules fn update_card_visibility( mut commands: Commands, mut cards: Query<(Entity, &Card, &Zone, Option<&VisibleTo>)>, players: Query<(Entity, &Player)>, ) { for (entity, card, zone, visible_to) in &mut cards { match zone.zone_type { ZoneType::Hand => { // Only visible to owner let owner_id = card.owner.id(); commands.entity(entity).insert(VisibleTo { player_ids: vec![owner_id], }); }, ZoneType::Battlefield => { // Visible to all players commands.entity(entity).remove::<VisibleTo>(); }, // Handle other zone types... } } } }
Networking Events
Define custom events for networking-related actions:
#![allow(unused)] fn main() { // src/networking/events.rs use bevy::prelude::*; use serde::{Serialize, Deserialize}; #[derive(Event, Serialize, Deserialize, Clone, Debug)] pub struct ClientActionRequest { pub player_id: u64, pub action_type: NetworkedActionType, pub targets: Vec<Entity>, // Additional parameters specific to the action type } #[derive(Serialize, Deserialize, Clone, Debug)] pub enum NetworkedActionType { PlayLand, CastSpell, ActivateAbility { ability_index: usize }, PassPriority, MulliganDecision { keep: bool }, CommanderZoneChoice { to_command_zone: bool }, DeclareAttackers { attackers: Vec<Entity> }, DeclareBlockers { blockers: Vec<(Entity, Entity)> }, // Additional action types as needed } // Implement server-to-client events for game updates #[derive(Event, Serialize, Deserialize, Clone, Debug)] pub struct GameStateUpdate { pub active_player: Entity, pub phase: Phase, pub priority_player: Option<Entity>, // Additional state information } }
Security Considerations
Security is crucial for card games to prevent cheating:
-
Server Authority: The server is the sole authority for game state. All client actions must be validated by the server.
-
Action Validation: Each client action must be validated against the current game state and rules.
-
Anti-Cheat Measures:
- Hidden information (hand cards) should only be sent to the appropriate player
- Random events (shuffling, coin flips) should be performed on the server
- Rate-limiting client requests to prevent DoS attacks
-
Reconnection Handling: Players should be able to reconnect to games in progress.
Testing and Debugging
For effective testing and debugging of the networking implementation:
- Local Testing: Simulate a networked environment on a single machine
#![allow(unused)] fn main() { // src/networking/testing.rs pub fn setup_local_test_environment(app: &mut App) { app .add_plugins(ServerPlugin) .add_plugins(ClientPlugin) .add_systems(Startup, spawn_test_clients); } }
-
Integration Tests: Dedicated tests for network functionality
-
Network Condition Simulation: Test under various network conditions (latency, packet loss)
-
Logging and Monitoring: Comprehensive logging of network events
#![allow(unused)] fn main() { fn log_network_events( connection_events: EventReader<ConnectionEvent>, client_events: EventReader<ClientActionRequest>, server_events: EventReader<GameStateUpdate>, ) { for event in connection_events.read() { info!("Connection event: {:?}", event); } // Log other events... } }
This document provides a high-level overview of implementing networking with bevy_replicon for the MTG Commander game engine. Implementation details will need to be adjusted based on specific game requirements and the evolving architecture of the game.
Connection Management
Data Synchronization
Error Handling
bevy_replicon Implementation Details
This document provides detailed implementation guidelines for integrating bevy_replicon into our MTG Commander game engine.
Table of Contents
- Project Structure
- Core Components
- Server Implementation
- Client Implementation
- Serialization Strategy
- Game-Specific Replication
- Testing Strategy
- Random Number Generator Synchronization
Project Structure
The networking implementation will be structured as follows:
src/
└── networking/
├── mod.rs # Module exports and plugin registration
├── plugin.rs # Main NetworkingPlugin
├── server/
│ ├── mod.rs # Server module exports
│ ├── plugin.rs # ServerPlugin implementation
│ ├── systems.rs # Server systems
│ ├── events.rs # Server-specific events
│ └── resources.rs # Server resources
├── client/
│ ├── mod.rs # Client module exports
│ ├── plugin.rs # ClientPlugin implementation
│ ├── systems.rs # Client systems
│ ├── events.rs # Client-specific events
│ └── resources.rs # Client resources
├── replication/
│ ├── mod.rs # Replication module exports
│ ├── components.rs # Replicable components
│ ├── registry.rs # Component and event registration
│ └── visibility.rs # Visibility control
├── protocol/
│ ├── mod.rs # Protocol module exports
│ ├── actions.rs # Networked actions
│ └── messages.rs # Custom messages
└── testing/
├── mod.rs # Testing module exports
├── simulation.rs # Network simulation
└── diagnostics.rs # Diagnostics tools
Core Components
Networking Plugin
#![allow(unused)] fn main() { // src/networking/plugin.rs use bevy::prelude::*; use crate::networking::server::ServerPlugin; use crate::networking::client::ClientPlugin; use crate::networking::replication::ReplicationPlugin; /// Configuration for the networking plugin #[derive(Resource)] pub struct NetworkingConfig { /// Whether this instance is running as a server pub is_server: bool, /// Whether this instance is running as a client pub is_client: bool, /// Server address to connect to (client only) pub server_address: Option<String>, /// Server port to host on (server) or connect to (client) pub port: u16, /// Maximum number of clients that can connect (server only) pub max_clients: usize, } impl Default for NetworkingConfig { fn default() -> Self { Self { is_server: false, is_client: true, server_address: None, port: 5000, max_clients: 4, } } } /// Main plugin for networking functionality pub struct NetworkingPlugin; impl Plugin for NetworkingPlugin { fn build(&self, app: &mut App) { // Add core networking resources app.init_resource::<NetworkingConfig>(); // Add replication plugin app.add_plugins(ReplicationPlugin); // Add server or client plugins based on configuration let config = app.world.resource::<NetworkingConfig>(); if config.is_server { app.add_plugins(ServerPlugin); } if config.is_client { app.add_plugins(ClientPlugin); } } } }
Server Implementation
Server Resource
#![allow(unused)] fn main() { // src/networking/server/resources.rs use bevy::prelude::*; use bevy_replicon::prelude::*; use std::collections::HashMap; /// Resource for managing the server state #[derive(Resource)] pub struct GameServer { /// Maps client IDs to player entities pub client_player_map: HashMap<ClientId, Entity>, /// Maps player entities to client IDs pub player_client_map: HashMap<Entity, ClientId>, /// Current game session ID pub session_id: String, /// Whether the server is accepting new connections pub accepting_connections: bool, /// Server status pub status: ServerStatus, } /// Server status #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ServerStatus { /// Server is starting up Starting, /// Server is waiting for players to connect WaitingForPlayers, /// Game is in progress GameInProgress, /// Game has ended GameEnded, } impl Default for GameServer { fn default() -> Self { Self { client_player_map: HashMap::new(), player_client_map: HashMap::new(), session_id: uuid::Uuid::new_v4().to_string(), accepting_connections: true, status: ServerStatus::Starting, } } } }
Server Systems
#![allow(unused)] fn main() { // src/networking/server/systems.rs use bevy::prelude::*; use bevy_replicon::prelude::*; use crate::networking::server::resources::*; use crate::networking::protocol::actions::*; use crate::game_engine::GameAction; /// Set up server resources pub fn setup_server(mut commands: Commands) { commands.insert_resource(GameServer::default()); } /// Handle player connections and disconnections pub fn handle_player_connections( mut commands: Commands, mut server: ResMut<GameServer>, mut server_events: EventReader<ServerEvent>, mut connected_clients: ResMut<ConnectedClients>, ) { for event in server_events.read() { match event { ServerEvent::ClientConnected { client_id } => { info!("Client connected: {:?}", client_id); // Create player entity for the client if server.accepting_connections { let player_entity = commands.spawn_empty().id(); // Map client to player server.client_player_map.insert(*client_id, player_entity); server.player_client_map.insert(player_entity, *client_id); // Start replication for this client commands.add(StartReplication { client_id: *client_id, }); // Add player to replicated clients list commands.entity(player_entity).insert(ReplicatedClient { client_id: *client_id, }); } } ServerEvent::ClientDisconnected { client_id, reason } => { info!("Client disconnected: {:?}, reason: {:?}", client_id, reason); // Remove player entity and mappings if let Some(player_entity) = server.client_player_map.remove(client_id) { server.player_client_map.remove(&player_entity); // Handle player disconnection in game logic // (e.g., mark player as AFK, save their state for reconnection, etc.) } } } } } /// Process action requests from clients pub fn process_action_requests( mut commands: Commands, mut action_requests: EventReader<ClientActionRequest>, server: Res<GameServer>, game_state: Res<crate::game_engine::state::GameState>, mut game_actions: EventWriter<GameAction>, ) { for request in action_requests.read() { // Validate client ID to player mapping if let Some(player_entity) = server.client_player_map.get(&request.client_id) { match &request.action { NetworkedAction::PlayLand { card_id } => { // Validate action against game rules if game_state.can_play_land(*player_entity) { // Convert to game action game_actions.send(GameAction::PlayLand { player: *player_entity, land_card: *card_id, }); } } NetworkedAction::CastSpell { card_id, targets, mana_payment } => { // Validate spell casting if game_state.can_cast_spell(*player_entity, *card_id) { game_actions.send(GameAction::CastSpell { player: *player_entity, spell_card: *card_id, targets: targets.clone(), mana_payment: mana_payment.clone(), }); } } // Handle other action types... _ => {} } } } } }
Client Implementation
Client Resources
#![allow(unused)] fn main() { // src/networking/client/resources.rs use bevy::prelude::*; use bevy_replicon::prelude::*; /// Resource for managing the client state #[derive(Resource)] pub struct GameClient { /// The client's local player entity pub local_player: Option<Entity>, /// Local player ID pub local_player_id: Option<u64>, /// Connection status pub connection_status: ConnectionStatus, /// Action queue for batching actions pub action_queue: Vec<NetworkedAction>, } /// Connection status #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConnectionStatus { /// Not connected to a server Disconnected, /// Attempting to connect to a server Connecting, /// Connected and authenticated Connected, /// Connection error occurred Error(ConnectionError), } /// Connection error types #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConnectionError { /// Failed to connect to the server ConnectionFailed, /// Connected but authentication failed AuthenticationFailed, /// Disconnected unexpectedly Disconnected, /// Timeout waiting for server response Timeout, } impl Default for GameClient { fn default() -> Self { Self { local_player: None, local_player_id: None, connection_status: ConnectionStatus::Disconnected, action_queue: Vec::new(), } } } }
Client Systems
#![allow(unused)] fn main() { // src/networking/client/systems.rs use bevy::prelude::*; use bevy_replicon::prelude::*; use crate::networking::client::resources::*; use crate::networking::protocol::actions::*; use crate::player::Player; /// Set up client resources pub fn setup_client(mut commands: Commands) { commands.insert_resource(GameClient::default()); } /// Handle connection status changes pub fn handle_connection_status( mut client: ResMut<GameClient>, mut client_status: ResMut<RepliconClientStatus>, ) { match *client_status { RepliconClientStatus::Connecting => { client.connection_status = ConnectionStatus::Connecting; } RepliconClientStatus::Connected => { client.connection_status = ConnectionStatus::Connected; } RepliconClientStatus::Disconnected => { client.connection_status = ConnectionStatus::Disconnected; } } } /// Update local player reference pub fn update_local_player( mut client: ResMut<GameClient>, players: Query<(Entity, &Player, &ReplicatedClient)>, replicon_client: Res<RepliconClient>, ) { // Find the player entity that belongs to this client if client.local_player.is_none() { for (entity, _player, replicated_client) in &players { if replicated_client.client_id == replicon_client.id() { client.local_player = Some(entity); client.local_player_id = Some(replicon_client.id().0); break; } } } } /// Send actions to the server pub fn send_player_actions( mut client: ResMut<GameClient>, mut action_requests: EventWriter<ClientActionRequest>, replicon_client: Res<RepliconClient>, ) { // Process queued actions for action in client.action_queue.drain(..) { let request = ClientActionRequest { client_id: replicon_client.id(), action: action, }; action_requests.send(request); } } /// Handle player input and queue actions pub fn handle_player_input( mut client: ResMut<GameClient>, input: Res<Input<KeyCode>>, mouse_input: Res<Input<MouseButton>>, card_interaction: Res<CardInteraction>, ) { // Example: Queue an action based on player input if mouse_input.just_pressed(MouseButton::Left) && card_interaction.selected_card.is_some() { if input.pressed(KeyCode::ShiftLeft) { // Queue a cast spell action client.action_queue.push(NetworkedAction::CastSpell { card_id: card_interaction.selected_card.unwrap(), targets: card_interaction.targets.clone(), mana_payment: card_interaction.proposed_payment.clone(), }); } else { // Queue a different action based on the card and context // ... } } } }
Serialization Strategy
For efficient serialization, we need to implement Serialize/Deserialize traits for all networked components:
#![allow(unused)] fn main() { // src/networking/replication/components.rs use bevy::prelude::*; use serde::{Serialize, Deserialize}; use crate::game_engine::phase::Phase; use crate::player::Player; use crate::mana::Mana; // Make sure all replicated components implement Serialize and Deserialize // For complex types, consider custom serialization for efficiency #[derive(Component, Serialize, Deserialize, Clone, Debug)] pub struct NetworkedPlayer { pub name: String, pub life: i32, pub mana_pool: Mana, // Include only data that needs to be networked // Omit any large or game-state-derived data } impl From<&Player> for NetworkedPlayer { fn from(player: &Player) -> Self { Self { name: player.name.clone(), life: player.life, mana_pool: player.mana_pool.clone(), } } } // Custom serialization for Entity references to handle cross-client referencing #[derive(Serialize, Deserialize, Clone, Debug)] pub struct NetworkedEntity { pub id: u64, // Stable ID that can be used across the network } // System to maintain Entity <-> NetworkedEntity mappings pub fn update_entity_mappings( mut commands: Commands, new_entities: Query<Entity, Added<Replicated>>, mut entity_map: ResMut<EntityMap>, ) { for entity in &new_entities { // Generate stable network ID let network_id = entity_map.next_id(); // Save mapping entity_map.insert(entity, network_id); // Add networked ID component commands.entity(entity).insert(NetworkedId(network_id)); } } }
Game-Specific Replication
MTG Card Replication
Special care needs to be taken for replicating MTG cards, as they have hidden information:
#![allow(unused)] fn main() { // src/networking/replication/visibility.rs use bevy::prelude::*; use bevy_replicon::prelude::*; use crate::card::Card; use crate::game_engine::zones::{Zone, ZoneType}; // System to update client visibility for cards pub fn update_card_visibility( mut commands: Commands, cards: Query<(Entity, &Card, &Zone)>, players: Query<(Entity, &ReplicatedClient)>, server: Res<RepliconServer>, replication_rules: Res<ReplicationRules>, ) { for (card_entity, card, zone) in &cards { match zone.zone_type { ZoneType::Hand => { // Only the owner can see cards in hand let owner_client_id = get_client_id_for_player(card.owner, &players); if let Some(owner_id) = owner_client_id { // Use ClientVisibility to control which clients can see this entity commands.entity(card_entity).insert(ClientVisibility { policy: VisibilityPolicy::Blacklist, client_ids: players .iter() .filter_map(|(_, client)| { if client.client_id != owner_id { Some(client.client_id) } else { None } }) .collect(), }); } }, ZoneType::Library => { // No player can see library cards (except the top in some cases) commands.entity(card_entity).insert(ClientVisibility { policy: VisibilityPolicy::Blacklist, client_ids: players .iter() .map(|(_, client)| client.client_id) .collect(), }); }, ZoneType::Battlefield => { // All players can see battlefield cards commands.entity(card_entity).remove::<ClientVisibility>(); }, ZoneType::Graveyard | ZoneType::Exile | ZoneType::Command => { // All players can see these zones commands.entity(card_entity).remove::<ClientVisibility>(); }, // Handle other zones... } } } // Helper function to get client ID for a player entity fn get_client_id_for_player( player_entity: Entity, players: &Query<(Entity, &ReplicatedClient)>, ) -> Option<ClientId> { players .iter() .find_map(|(entity, client)| { if entity == player_entity { Some(client.client_id) } else { None } }) } }
Testing Strategy
Network Simulation
#![allow(unused)] fn main() { // src/networking/testing/simulation.rs use bevy::prelude::*; use crate::networking::plugin::NetworkingPlugin; use crate::networking::server::ServerPlugin; use crate::networking::client::ClientPlugin; /// Plugin for testing the networking in a local environment pub struct NetworkTestPlugin; impl Plugin for NetworkTestPlugin { fn build(&self, app: &mut App) { // Create a local server and client app.insert_resource(NetworkingConfig { is_server: true, // This instance acts as both server and client is_client: true, server_address: Some("127.0.0.1".to_string()), port: 5000, max_clients: 4, }); app.add_plugins(NetworkingPlugin); // Add systems for simulating network conditions app.add_systems(Update, simulate_network_conditions); } } /// System to simulate various network conditions for testing pub fn simulate_network_conditions( mut server: ResMut<RepliconServer>, mut client: ResMut<RepliconClient>, network_simulation: Res<NetworkSimulation>, ) { // Simulate latency if let Some(latency) = network_simulation.latency { // Delay processing of messages std::thread::sleep(std::time::Duration::from_millis(latency)); } // Simulate packet loss if let Some(packet_loss) = network_simulation.packet_loss { let mut rng = rand::thread_rng(); if rng.gen::<f32>() < packet_loss { // Simulate packet loss by not processing some messages // This would require modifications to the underlying transport layer } } } /// Resource for configuring network simulation #[derive(Resource)] pub struct NetworkSimulation { /// Simulated latency in milliseconds pub latency: Option<u64>, /// Packet loss rate (0.0 to 1.0) pub packet_loss: Option<f32>, /// Jitter in milliseconds pub jitter: Option<u64>, } impl Default for NetworkSimulation { fn default() -> Self { Self { latency: None, packet_loss: None, jitter: None, } } } }
Integration with Game Loop
#![allow(unused)] fn main() { // src/networking/integration.rs use bevy::prelude::*; use crate::networking::plugin::NetworkingPlugin; use crate::game_engine::state::GameState; /// System to initialize networking based on game mode pub fn initialize_networking( mut commands: Commands, game_state: Res<GameState>, mut app_config: ResMut<NetworkingConfig>, ) { match game_state.mode { GameMode::SinglePlayer => { // No networking needed app_config.is_server = false; app_config.is_client = false; }, GameMode::HostMultiplayer => { // Host acts as both server and client app_config.is_server = true; app_config.is_client = true; app_config.server_address = Some("0.0.0.0".to_string()); // Bind to all interfaces }, GameMode::JoinMultiplayer { server_address } => { // Client-only mode app_config.is_server = false; app_config.is_client = true; app_config.server_address = Some(server_address.clone()); } } } }
Random Number Generator Synchronization
Overview
Deterministic random number generation is critical for multiplayer games to ensure that all clients produce identical results when processing the same game actions. This section outlines how to use bevy_rand
and bevy_prng
to maintain synchronized RNG state across network boundaries.
#![allow(unused)] fn main() { // src/networking/rng/plugin.rs use bevy::prelude::*; use bevy_prng::WyRand; use bevy_rand::prelude::*; use crate::networking::server::resources::GameServer; /// Plugin for handling RNG synchronization in networked games pub struct NetworkedRngPlugin; impl Plugin for NetworkedRngPlugin { fn build(&self, app: &mut App) { // Register the WyRand PRNG with bevy_rand app.add_plugins(EntropyPlugin::<WyRand>::default()) .add_systems(Update, synchronize_rng_state) .add_systems(PostUpdate, handle_rng_state_replication); } } /// Resource to track the RNG state for replication #[derive(Resource)] pub struct RngStateTracker { /// The current state of the global RNG pub global_state: Vec<u8>, /// Last synchronization timestamp pub last_sync: f32, /// Whether the RNG state has changed since last sync pub dirty: bool, } impl Default for RngStateTracker { fn default() -> Self { Self { global_state: Vec::new(), last_sync: 0.0, dirty: false, } } } /// System to capture RNG state for replication pub fn synchronize_rng_state( mut rng: GlobalEntropy<WyRand>, mut state_tracker: ResMut<RngStateTracker>, time: Res<Time>, ) { // Only sync periodically to reduce network traffic if time.elapsed_seconds() - state_tracker.last_sync > 5.0 { // Serialize the RNG state if let Some(serialized_state) = rng.try_serialize_state() { state_tracker.global_state = serialized_state; state_tracker.last_sync = time.elapsed_seconds(); state_tracker.dirty = true; } } } /// System to handle replication of RNG state to clients pub fn handle_rng_state_replication( server: Option<Res<GameServer>>, rng_state: Res<RngStateTracker>, mut client: ResMut<RepliconClient>, mut server_res: ResMut<RepliconServer>, ) { // Only the server should send RNG state updates if let Some(server) = server { if rng_state.dirty { // Send RNG state to all clients for client_id in server.client_player_map.keys() { server_res.send_message( *client_id, ServerChannel::Reliable, bincode::serialize(&RngStateMessage { state: rng_state.global_state.clone(), timestamp: rng_state.last_sync, }).unwrap(), ); } } } } /// Message for RNG state synchronization #[derive(Serialize, Deserialize, Clone, Debug)] pub struct RngStateMessage { /// Serialized RNG state pub state: Vec<u8>, /// Timestamp of the state pub timestamp: f32, } }
Player-Specific RNG Components
Each player should have their own RNG component that is deterministically seeded from the global source:
#![allow(unused)] fn main() { // src/player/components/player_rng.rs use bevy::prelude::*; use bevy_prng::WyRand; use bevy_rand::prelude::*; /// Component for player-specific randomization #[derive(Component)] pub struct PlayerRng { /// The player's RNG component pub rng: Entropy<WyRand>, /// Whether this RNG is remotely controlled pub is_remote: bool, } /// System to initialize player RNGs pub fn setup_player_rngs( mut commands: Commands, players: Query<(Entity, &Player), Without<PlayerRng>>, mut global_rng: GlobalEntropy<WyRand>, server: Option<Res<GameServer>>, ) { for (entity, player) in players.iter() { // On the server, create a new RNG for each player let is_remote = server.is_none() || !server.unwrap().is_server_player(entity); // Fork from the global RNG to maintain determinism commands.entity(entity).insert(PlayerRng { rng: global_rng.fork_rng(), is_remote, }); } } }
Client-Side RNG Management
Clients need to apply RNG state updates from the server:
#![allow(unused)] fn main() { // src/networking/client/systems.rs use bevy::prelude::*; use bevy_prng::WyRand; use bevy_rand::prelude::*; use crate::networking::rng::RngStateMessage; /// System to handle RNG state updates from server pub fn handle_rng_state_update( mut client_rng: GlobalEntropy<WyRand>, mut incoming_messages: EventReader<NetworkMessage>, ) { for message in incoming_messages.read() { if let Ok(rng_message) = bincode::deserialize::<RngStateMessage>(&message.data) { // Apply the server's RNG state client_rng.deserialize_state(&rng_message.state).expect("Failed to deserialize RNG state"); // Now client and server have synchronized RNG state } } } }
Deterministic Usage in Game Actions
To ensure deterministic behavior, game actions must use RNG components in a consistent way:
#![allow(unused)] fn main() { // src/game_engine/actions/dice_roll.rs use bevy::prelude::*; use crate::player::components::PlayerRng; use rand::Rng; /// System to handle dice roll actions pub fn handle_dice_roll( mut commands: Commands, mut dice_roll_events: EventReader<DiceRollEvent>, mut players: Query<(&mut PlayerRng, &Player)>, ) { for event in dice_roll_events.read() { if let Ok((mut player_rng, player)) = players.get_mut(event.player_entity) { // Use the player's RNG to get a deterministic result let roll_result = player_rng.rng.gen_range(1..=event.sides); // Create the effect based on the roll result let effect_entity = commands.spawn(DiceRollEffect { player: event.player_entity, result: roll_result, sides: event.sides, }).id(); // The effect is determined by the RNG, ensuring all clients get the same result // as long as they have the same RNG state and process events in the same order } } } }
Ensuring Consistency
To maintain RNG consistency across clients:
- The server is the authoritative source of RNG state
- All random operations use player-specific RNGs or the global RNG, never
thread_rng()
or other non-deterministic sources - RNG state is synchronized periodically
- Game actions that use randomness include a sequence ID to ensure they're processed in the same order on all clients
- When a new player joins, they receive the current global RNG state as part of initialization
Advanced: Network Partitioning
For scenarios where different subsets of players may need different random sequences (like shuffling a deck that only certain players should know the order of):
#![allow(unused)] fn main() { // src/game_engine/zones/library.rs use bevy::prelude::*; use crate::player::components::PlayerRng; /// System to shuffle a player's library pub fn shuffle_library( mut libraries: Query<(&mut Library, &Owner)>, mut players: Query<&mut PlayerRng>, shuffle_events: EventReader<ShuffleLibraryEvent>, ) { for event in shuffle_events.read() { if let Ok((mut library, owner)) = libraries.get_mut(event.library_entity) { // Get the owner's RNG if let Ok(mut player_rng) = players.get_mut(owner.entity) { // Use the player's RNG to shuffle the library deterministically library.shuffle_with_rng(&mut player_rng.rng); // This ensures that all clients who have access to this player's // library will see the same shuffle result } } } } }
By following these patterns, your MTG Commander game will maintain consistent random results across all networked clients, ensuring fair gameplay regardless of network conditions.
Multiplayer Commander Networking
This document focuses on the specific networking considerations for supporting multiplayer Commander games with 4+ players. Commander games have unique requirements that affect networking design and implementation.
Table of Contents
- Multiplayer Commander Format
- Scaling Considerations
- Player Interactions
- Politics System
- Zone Visibility
- Event Broadcasting
- State Management
Multiplayer Commander Format
Commander is a multiplayer-focused format with specific rules that affect networking:
- Games typically involve 3-6 players (with 4 being the standard)
- Each player has a designated legendary creature as their commander
- Players start at 40 life instead of 20
- Commander damage tracking (21+ damage from a single commander eliminates a player)
- Free-for-all political gameplay with temporary alliances
- Complex board states with many permanents
- Games can last multiple hours
These characteristics require special networking considerations beyond those of typical 1v1 Magic games.
Scaling Considerations
Player Count Scaling
#![allow(unused)] fn main() { /// Configuration for multiplayer Commander games #[derive(Resource)] pub struct CommanderMultiplayerConfig { /// Minimum number of players required to start pub min_players: usize, /// Maximum number of players allowed pub max_players: usize, /// Whether to allow spectators pub allow_spectators: bool, /// Maximum number of spectators pub max_spectators: Option<usize>, /// Timeout for player actions (in seconds) pub player_timeout: Option<u32>, /// Whether to enable turn timer pub use_turn_timer: bool, /// Turn time limit (in seconds) pub turn_time_limit: Option<u32>, } impl Default for CommanderMultiplayerConfig { fn default() -> Self { Self { min_players: 2, // Support even 1v1 Commander max_players: 6, // Default to classic pod size allow_spectators: true, max_spectators: Some(10), player_timeout: Some(60), // 1 minute default timeout use_turn_timer: false, turn_time_limit: Some(120), // 2 minutes per turn } } } }
Data Volume Optimization
With 4+ players, the amount of state data increases significantly. Optimizing data transmission is crucial:
#![allow(unused)] fn main() { /// System to optimize network traffic based on player count pub fn optimize_data_transmission( player_count: Res<PlayerCount>, mut server_config: ResMut<ServerReplicationConfig>, ) { // Adjust replication frequency based on player count match player_count.active { 1..=2 => { // For 1-2 players, use higher frequency updates server_config.replication_frequency = 30; // ~30Hz } 3..=4 => { // For 3-4 players, use moderate frequency server_config.replication_frequency = 20; // ~20Hz } _ => { // For 5+ players, reduce update frequency server_config.replication_frequency = 10; // ~10Hz } } // Adjust component replication priorities if player_count.active > 4 { // Prioritize critical components in high-player-count games server_config.priority_components = vec![ ComponentPriority { type_id: TypeId::of::<Player>(), priority: 10 }, ComponentPriority { type_id: TypeId::of::<Phase>(), priority: 9 }, ComponentPriority { type_id: TypeId::of::<Commander>(), priority: 8 }, // Less critical components get lower priority ComponentPriority { type_id: TypeId::of::<CardArt>(), priority: 1 }, ]; } } }
Player Interactions
Commander games feature unique player-to-player interactions that must be properly networked:
Table Politics
#![allow(unused)] fn main() { #[derive(Event, Serialize, Deserialize, Clone, Debug)] pub struct PoliticalDealProposal { /// Player proposing the deal pub proposer: Entity, /// Player receiving the proposal pub target: Entity, /// The proposed deal terms pub terms: Vec<DealTerm>, /// How long the deal should last pub duration: DealDuration, } #[derive(Serialize, Deserialize, Clone, Debug)] pub enum DealTerm { /// Promise not to attack NonAggression, /// Promise to attack another player AttackPlayer(Entity), /// Promise not to counter spells NonCountering, /// Promise to help with a specific threat RemoveThreat(Entity), /// One-time favor (free attack, no blocks, etc.) OneTimeFavor(FavorType), /// Custom text agreement Custom(String), } /// System to handle political deal proposals and responses pub fn handle_political_deals( mut server: ResMut<RepliconServer>, mut proposals: EventReader<FromClient<PoliticalDealProposal>>, mut responses: EventReader<FromClient<PoliticalDealResponse>>, mut active_deals: ResMut<ActiveDeals>, connected_clients: Res<ConnectedClients>, ) { // Process new deal proposals for FromClient { client_id, event } in proposals.read() { // Validate the proposal if validate_deal_proposal(&event, &connected_clients) { // Forward the proposal to the target player if let Some(target_client_id) = get_client_id_for_player(event.target) { server.send( target_client_id, RepliconChannel::UnreliableOrdered, bincode::serialize(&event).unwrap() ); } } } // Process deal responses for FromClient { client_id, event } in responses.read() { if event.accepted { // Add to active deals if accepted active_deals.add(event.proposal_id.clone(), event.proposal.clone()); // Notify both parties let notification = DealAcceptedNotification { deal_id: event.proposal_id.clone(), }; notify_deal_participants(&mut server, &event.proposal, ¬ification); } else { // Notify of rejection let notification = DealRejectedNotification { deal_id: event.proposal_id.clone(), }; notify_deal_participants(&mut server, &event.proposal, ¬ification); } } } }
Voting Mechanism
Commander often involves voting effects from cards:
#![allow(unused)] fn main() { #[derive(Event, Serialize, Deserialize, Clone, Debug)] pub struct VoteProposal { /// The card or effect that initiated the vote pub source: Entity, /// The player who controls the voting effect pub controller: Entity, /// The available choices pub choices: Vec<VoteChoice>, /// Time limit for voting (in seconds) pub time_limit: Option<u32>, } #[derive(Event, Serialize, Deserialize, Clone, Debug)] pub struct PlayerVote { /// The vote proposal ID pub proposal_id: Uuid, /// The player casting the vote pub player: Entity, /// The chosen option pub choice: usize, } /// System to synchronize voting across all players pub fn handle_voting( mut server: ResMut<RepliconServer>, mut vote_proposals: EventReader<FromClient<VoteProposal>>, mut player_votes: EventReader<FromClient<PlayerVote>>, mut active_votes: ResMut<ActiveVotes>, connected_clients: Res<ConnectedClients>, ) { // Process new vote proposals from effects for FromClient { client_id, event } in vote_proposals.read() { // Validate that the client is authorized to initiate this vote if validate_vote_proposal(&event, client_id, &connected_clients) { // Create and store the vote let vote_id = Uuid::new_v4(); active_votes.add(vote_id, event.clone()); // Broadcast to all eligible voters for player_entity in get_eligible_voters(&event) { if let Some(player_client_id) = get_client_id_for_player(player_entity) { let vote_notification = VoteStartedNotification { vote_id, proposal: event.clone(), }; server.send( player_client_id, RepliconChannel::ReliableOrdered, bincode::serialize(&vote_notification).unwrap() ); } } } } // Process incoming votes for FromClient { client_id, event } in player_votes.read() { if let Some(vote) = active_votes.get_mut(&event.proposal_id) { // Record the vote vote.record_vote(event.player, event.choice); // Check if voting is complete if vote.is_complete() { // Determine the result let result = vote.tally_result(); // Broadcast the result to all participants let result_notification = VoteCompletedNotification { vote_id: event.proposal_id, result, }; for participant in vote.participants() { if let Some(participant_client_id) = get_client_id_for_player(participant) { server.send( participant_client_id, RepliconChannel::ReliableOrdered, bincode::serialize(&result_notification).unwrap() ); } } } } } } }
Politics System
Commander games feature political elements that affect gameplay:
#![allow(unused)] fn main() { /// Architecture for politics system in Commander pub struct PoliticsPlugin; impl Plugin for PoliticsPlugin { fn build(&self, app: &mut App) { app // Resources .init_resource::<ActiveDeals>() .init_resource::<ActiveVotes>() .init_resource::<Alliances>() .init_resource::<CombatRestrictions>() .init_resource::<MonarchState>() // Components .register_type::<PoliticalDeal>() .register_type::<Alliance>() .register_type::<VoteWeight>() .register_type::<Monarch>() .register_type::<Goad>() .register_type::<CombatRestriction>() // Events .add_event::<PoliticalDealProposal>() .add_event::<PoliticalDealResponse>() .add_event::<VoteProposal>() .add_event::<PlayerVote>() .add_event::<MonarchChangeEvent>() .add_event::<GoadEvent>() // Systems .add_systems(Update, ( handle_political_deals, handle_voting, track_monarch, enforce_goad, update_combat_restrictions, check_deal_expirations, )) // Replicate components and events .replicate::<PoliticalDeal>() .replicate::<Alliance>() .replicate::<VoteWeight>() .replicate::<Monarch>() .replicate::<Goad>() .replicate_event::<MonarchChangeEvent>() .replicate_event::<VoteCompletedNotification>() .replicate_event::<DealAcceptedNotification>() .replicate_event::<DealRejectedNotification>(); } } /// System to enforce goad effects, which are political combat-forcing effects pub fn enforce_goad( mut commands: Commands, goaded_creatures: Query<(Entity, &Goad)>, mut attack_restrictions: ResMut<CombatRestrictions>, time: Res<Time>, ) { // Process all goaded creatures for (entity, goad) in &goaded_creatures { // Check if the goad effect has expired if let Some(expiration) = goad.expires_at { if time.elapsed_seconds() > expiration { // Remove expired goad effect commands.entity(entity).remove::<Goad>(); attack_restrictions.remove_restriction(entity, RestrictionType::MustAttack); } else { // Ensure the creature has attack restrictions if !attack_restrictions.has_restriction(entity, RestrictionType::MustAttack) { attack_restrictions.add_restriction( entity, RestrictionType::MustAttack(goad.goaded_by), ); } } } } } }
Zone Visibility
Commander requires special handling for zone visibility:
#![allow(unused)] fn main() { /// System to manage zone visibility in multiplayer Commander pub fn manage_zone_visibility( mut commands: Commands, cards: Query<(Entity, &Card, &Zone, Option<&ClientVisibility>)>, players: Query<(Entity, &ReplicatedClient)>, reveal_events: EventReader<CardRevealEvent>, ) { // Special case: Command Zone // Command zone is public, so commanders are visible to all players for (entity, card, zone, _) in cards.iter().filter(|(_, _, z, _)| z.zone_type == ZoneType::Command) { // Ensure commanders are visible to all commands.entity(entity).remove::<ClientVisibility>(); } // Handle hidden zones (hand, library) for (entity, card, zone, existing_visibility) in cards.iter().filter(|(_, _, z, _)| z.zone_type == ZoneType::Hand || z.zone_type == ZoneType::Library ) { match zone.zone_type { ZoneType::Hand => { // Only the owner can see cards in hand if let Some(owner_client_id) = get_client_id_for_player(card.owner, &players) { let blacklist: Vec<ClientId> = players .iter() .filter_map(|(_, client)| { if client.client_id != owner_client_id { Some(client.client_id) } else { None } }) .collect(); commands.entity(entity).insert(ClientVisibility { policy: VisibilityPolicy::Blacklist, client_ids: blacklist, }); } }, ZoneType::Library => { // Libraries are hidden from all players let all_clients: Vec<ClientId> = players .iter() .map(|(_, client)| client.client_id) .collect(); commands.entity(entity).insert(ClientVisibility { policy: VisibilityPolicy::Blacklist, client_ids: all_clients, }); }, _ => {}, } } // Process reveal events (when cards are revealed to specific players) for reveal_event in reveal_events.read() { if let Ok((entity, card, zone, _)) = cards.get(reveal_event.card) { // Create a whitelist of players who can see this revealed card let whitelist: Vec<ClientId> = reveal_event.visible_to .iter() .filter_map(|player| get_client_id_for_player(*player, &players)) .collect(); if !whitelist.is_empty() { commands.entity(entity).insert(ClientVisibility { policy: VisibilityPolicy::Whitelist, client_ids: whitelist, }); // Schedule automatic un-reveal after the specified duration if let Some(duration) = reveal_event.duration { commands.entity(entity).insert(TemporaryReveal { revert_at: time.elapsed_seconds() + duration, original_zone: zone.zone_type, }); } } } } } }
Event Broadcasting
Commander games generate many events that need to be broadcast to players:
#![allow(unused)] fn main() { /// System to efficiently broadcast game events to players pub fn broadcast_game_events( mut server: ResMut<RepliconServer>, mut turn_events: EventReader<TurnEvent>, mut combat_events: EventReader<CombatEvent>, mut commander_events: EventReader<CommanderEvent>, mut state_events: EventReader<GameStateEvent>, connected_clients: Res<ConnectedClients>, ) { // Create a batch of events to send to each client let mut client_event_batches: HashMap<ClientId, Vec<GameEvent>> = HashMap::new(); // Process turn events (turn start, phase changes, etc.) for event in turn_events.read() { // Turn events are public and sent to all players for client_id in connected_clients.iter() { client_event_batches .entry(*client_id) .or_default() .push(GameEvent::Turn(event.clone())); } } // Process combat events for event in combat_events.read() { // Combat events are public and sent to all players for client_id in connected_clients.iter() { client_event_batches .entry(*client_id) .or_default() .push(GameEvent::Combat(event.clone())); } } // Process commander-specific events for event in commander_events.read() { match event { CommanderEvent::CommanderCast { player, commander } => { // Public event, send to all for client_id in connected_clients.iter() { client_event_batches .entry(*client_id) .or_default() .push(GameEvent::Commander(event.clone())); } }, CommanderEvent::CommanderDamage { source, target, amount } => { // Public event, send to all for client_id in connected_clients.iter() { client_event_batches .entry(*client_id) .or_default() .push(GameEvent::Commander(event.clone())); } }, CommanderEvent::PlayerEliminated { player, reason } => { // Public event, send to all for client_id in connected_clients.iter() { client_event_batches .entry(*client_id) .or_default() .push(GameEvent::Commander(event.clone())); } }, } } // Process game state events for event in state_events.read() { // Game state events might contain hidden information // Filter based on event type and player visibility match event { GameStateEvent::CardDrawn { player, card } => { // Only notify the player who drew the card if let Some(client_id) = get_client_id_for_player(*player) { client_event_batches .entry(client_id) .or_default() .push(GameEvent::State(event.clone())); } // Notify others of a card drawn but hide the card identity let public_event = GameStateEvent::CardDrawn { player: *player, card: Entity::PLACEHOLDER, // Hide the actual card }; for client_id in connected_clients.iter() { if Some(*client_id) != get_client_id_for_player(*player) { client_event_batches .entry(*client_id) .or_default() .push(GameEvent::State(public_event.clone())); } } }, GameStateEvent::LibrarySearched { player, cards_revealed } => { // Only the searching player sees the cards if let Some(client_id) = get_client_id_for_player(*player) { client_event_batches .entry(client_id) .or_default() .push(GameEvent::State(event.clone())); } // Others just know a search happened let public_event = GameStateEvent::LibrarySearched { player: *player, cards_revealed: Vec::new(), // Hide the actual cards }; for client_id in connected_clients.iter() { if Some(*client_id) != get_client_id_for_player(*player) { client_event_batches .entry(*client_id) .or_default() .push(GameEvent::State(public_event.clone())); } } }, // Public events are sent to everyone GameStateEvent::LifeTotalChanged { player, new_total, change } | GameStateEvent::ManaAdded { player, mana } | GameStateEvent::PermanentEntered { permanent, controller } => { for client_id in connected_clients.iter() { client_event_batches .entry(*client_id) .or_default() .push(GameEvent::State(event.clone())); } }, } } // Send batched events to clients for (client_id, events) in client_event_batches { if !events.is_empty() { let event_batch = EventBatch { events, timestamp: time.elapsed_seconds(), }; server.send( client_id, RepliconChannel::ReliableOrdered, bincode::serialize(&event_batch).unwrap(), ); } } } }
State Management
For large Commander games, state management needs special attention:
#![allow(unused)] fn main() { /// Enhanced state sync system for multiplayer Commander pub fn sync_commander_game_state( mut commands: Commands, mut server: ResMut<RepliconServer>, game_state: Res<GameState>, turn_manager: Res<TurnManager>, zone_manager: Res<ZoneManager>, player_query: Query<(Entity, &Player, &CommanderState)>, connected_clients: Res<ConnectedClients>, mut client_sync_status: ResMut<ClientSyncStatus>, time: Res<Time>, ) { // Determine which clients need full or delta syncs for client_id in connected_clients.iter() { let client_status = client_sync_status.entry(*client_id).or_insert(SyncStatus { last_full_sync: 0.0, last_delta_sync: 0.0, sync_generation: 0, }); let current_time = time.elapsed_seconds(); // Client needs a full sync if: // 1. Never received a full sync before // 2. Haven't received a full sync in a long time // 3. Just connected let needs_full_sync = client_status.last_full_sync == 0.0 || current_time - client_status.last_full_sync > 30.0 || client_status.sync_generation == 0; // Regular delta sync interval let needs_delta_sync = current_time - client_status.last_delta_sync > 0.25; // 4 Hz if needs_full_sync { // Send full game state to client send_full_game_state(&mut server, *client_id, &game_state, &turn_manager, &zone_manager, &player_query); // Update sync status client_status.last_full_sync = current_time; client_status.last_delta_sync = current_time; client_status.sync_generation += 1; } else if needs_delta_sync { // Send delta update send_delta_update(&mut server, *client_id, &game_state, &turn_manager, &player_query); // Update sync status client_status.last_delta_sync = current_time; } } } /// Optimized serialization with compression for large game states fn send_full_game_state( server: &mut ResMut<RepliconServer>, client_id: ClientId, game_state: &Res<GameState>, turn_manager: &Res<TurnManager>, zone_manager: &Res<ZoneManager>, player_query: &Query<(Entity, &Player, &CommanderState)>, ) { // Create a full game state snapshot let mut snapshot = GameStateSnapshot { is_full_sync: true, sync_generation: time.frame_count(), turn: turn_manager.turn_number, phase: turn_manager.current_phase.clone(), active_player: turn_manager.active_player, priority_player: turn_manager.priority_player, players: Vec::new(), zones: Vec::new(), permanents: Vec::new(), stack: Vec::new(), }; // Add player data (filtering based on client visibility) for (entity, player, commander_state) in player_query.iter() { snapshot.players.push(PlayerSnapshot { entity, name: player.name.clone(), life: player.life, mana_pool: player.mana_pool.clone(), commander: commander_state.commander, commander_casts: commander_state.cast_count, commander_damage_received: commander_state.commander_damage_received.clone(), }); } // Add zone data for (zone_entity, zone) in zone_manager.zones.iter() { // Filter cards based on visibility to this client let visible_cards = filter_visible_cards(zone, client_id); snapshot.zones.push(ZoneSnapshot { entity: *zone_entity, zone_type: zone.zone_type, owner: zone.owner, cards: visible_cards, }); } // Add permanents on battlefield for permanent in game_state.battlefield.iter() { if let Ok(permanent_data) = get_permanent_data(*permanent) { snapshot.permanents.push(permanent_data); } } // Add stack items for stack_item in game_state.stack.items() { snapshot.stack.push(StackItemSnapshot { entity: stack_item.entity, source: stack_item.source, controller: stack_item.controller, targets: stack_item.targets.clone(), }); } // Compress the snapshot to reduce bandwidth usage let serialized = bincode::serialize(&snapshot).unwrap(); let compressed = compress_data(&serialized); // Send the compressed snapshot server.send( client_id, RepliconChannel::ReliableOrdered, Bytes::from(compressed), ); } }
This document provides detailed guidance on the specific networking considerations for multiplayer Commander games, focusing on scaling challenges, political elements, and the complexity of managing large game states across many players.
Networking Protocol Specification
This document defines the protocol used for networking in our MTG Commander game engine. It outlines the message formats, synchronization mechanisms, and handling of game-specific concepts.
Table of Contents
- Message Format
- Connection Handshake
- Authentication
- Game State Synchronization
- Player Actions
- Card Interactions
- MTG-Specific Handling
- Error Handling
Message Format
All networked messages follow a standardized format using bevy_replicon's structure:
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Clone, Debug)] pub struct NetworkMessage<T> { /// Message type identifier pub message_type: MessageType, /// Payload data pub payload: T, /// Sequence number for ordering pub sequence: u64, /// Timestamp when the message was created pub timestamp: f64, } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub enum MessageType { /// Game state synchronization GameState, /// Player action Action, /// System message System, /// Chat message Chat, /// Error message Error, } }
Connection Handshake
The connection handshake establishes a client-server relationship and verifies compatibility:
- Client Request: Client sends connection request with version information
- Server Validation: Server validates compatibility and available slots
- Connection Accept/Reject: Server sends acceptance or rejection
- Game State Sync: Server sends initial game state if accepted
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Clone, Debug)] pub struct ConnectionRequest { /// Client version pub version: String, /// Player name/identifier pub player_name: String, /// Unique client identifier pub client_id: Option<u64>, /// Reconnection token for game in progress (if any) pub reconnect_token: Option<String>, } #[derive(Serialize, Deserialize, Clone, Debug)] pub struct ConnectionResponse { /// Whether the connection was accepted pub accepted: bool, /// Reason for rejection if applicable pub rejection_reason: Option<String>, /// Server-assigned client ID pub assigned_client_id: Option<u64>, /// Session identifier pub session_id: Option<String>, } }
Authentication
For secure multiplayer, we implement a simple authentication system:
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Clone, Debug)] pub struct AuthRequest { /// Username or identifier pub username: String, /// Password or token pub password_hash: String, } #[derive(Serialize, Deserialize, Clone, Debug)] pub struct AuthResponse { /// Whether authentication was successful pub success: bool, /// Authentication token for future use pub token: Option<String>, /// Expiration time for the token pub expiration: Option<f64>, } }
Game State Synchronization
Game state synchronization happens at multiple levels:
- Full State Sync: Complete game state sent on connection or major changes
- Delta Updates: Only changed components sent during normal gameplay
- Targeted Updates: Some updates only sent to specific clients (for hidden information)
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Clone, Debug)] pub struct GameStateSync { /// Whether this is a full sync or delta pub is_full_sync: bool, /// Current game turn pub turn: u32, /// Current game phase pub phase: Phase, /// Active player entity ID pub active_player: u64, /// Priority player entity ID pub priority_player: Option<u64>, /// Entity changes (components) pub entity_changes: Vec<EntityChange>, /// Game events that occurred pub events: Vec<GameEvent>, } #[derive(Serialize, Deserialize, Clone, Debug)] pub struct EntityChange { /// Entity ID pub entity_id: u64, /// Component changes pub components: Vec<ComponentChange>, /// Whether the entity was created pub is_new: bool, /// Whether the entity was destroyed pub is_removed: bool, } }
Player Actions
Players send action requests to the server, which validates and processes them:
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Clone, Debug)] pub struct PlayerActionRequest { /// Client ID pub client_id: u64, /// Action type pub action_type: NetworkedActionType, /// Target entities pub targets: Vec<u64>, /// Action parameters pub parameters: Option<ActionParameters>, } #[derive(Serialize, Deserialize, Clone, Debug)] pub enum NetworkedActionType { /// Play a land card PlayLand, /// Cast a spell CastSpell, /// Activate an ability ActivateAbility, /// Declare attackers DeclareAttackers, /// Declare blockers DeclareBlockers, /// Pass priority PassPriority, /// Make a mulligan decision Mulligan, /// Choose to put a commander in the command zone CommanderZoneChoice, /// Stack response RespondToStack, /// Choose targets ChooseTargets, } #[derive(Serialize, Deserialize, Clone, Debug)] pub struct ActionParameters { /// Mana payment information pub mana_payment: Option<Mana>, /// Ability index (for cards with multiple abilities) pub ability_index: Option<usize>, /// X value for spells with X in cost pub x_value: Option<u32>, /// Selected mode for modal spells pub selected_mode: Option<u32>, /// Additional costs paid pub additional_costs: Option<Vec<AdditionalCost>>, } }
Card Interactions
Card interactions require special handling for targeting, stack effects, and zone changes:
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Clone, Debug)] pub struct TargetSelection { /// Source card/ability pub source: u64, /// Selected targets pub targets: Vec<Target>, /// Whether all required targets have been selected pub is_complete: bool, } #[derive(Serialize, Deserialize, Clone, Debug)] pub struct Target { /// Target entity ID pub entity_id: u64, /// Target type pub target_type: TargetType, } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub enum TargetType { /// Player Player, /// Creature Creature, /// Permanent Permanent, /// Spell on stack Spell, /// Zone Zone, } #[derive(Serialize, Deserialize, Clone, Debug)] pub struct StackEffect { /// Effect ID pub id: u64, /// Source entity pub source: u64, /// Controller pub controller: u64, /// Targets pub targets: Vec<Target>, /// Effect details pub effect: EffectDetails, } }
MTG-Specific Handling
MTG has unique concepts that require special handling:
Hidden Information
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Clone, Debug)] pub struct HiddenInformation { /// Zone type containing hidden information pub zone: ZoneType, /// Owner of the zone pub owner: u64, /// Hidden card IDs pub card_ids: Vec<u64>, /// Whether zone contents were reordered pub reordered: bool, } #[derive(Serialize, Deserialize, Clone, Debug)] pub struct RevealedCard { /// Card entity ID pub card_id: u64, /// Zone before reveal pub from_zone: ZoneType, /// Players who can see the card pub visible_to: Vec<u64>, /// Reveal source (card/effect that caused reveal) pub reveal_source: Option<u64>, } }
Randomization
To prevent cheating, all randomization happens on the server:
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Clone, Debug)] pub struct RandomizationRequest { /// Type of randomization pub randomization_type: RandomizationType, /// Entities involved pub entities: Vec<u64>, /// Additional parameters pub parameters: Option<RandomizationParameters>, } #[derive(Serialize, Deserialize, Clone, Debug)] pub enum RandomizationType { /// Shuffle a library ShuffleLibrary, /// Flip a coin CoinFlip, /// Roll a die DieRoll, /// Select random targets RandomTargets, /// Random card discard RandomDiscard, } }
Priority System
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Clone, Debug)] pub struct PriorityUpdate { /// Player with priority pub priority_player: u64, /// Time left to respond (in milliseconds) pub time_remaining: Option<u64>, /// What's currently on the stack pub stack_size: usize, /// Current phase/step pub current_phase: Phase, } }
Error Handling
#![allow(unused)] fn main() { #[derive(Serialize, Deserialize, Clone, Debug)] pub struct NetworkError { /// Error code pub code: u32, /// Error message pub message: String, /// Related entity (if applicable) pub related_entity: Option<u64>, /// Related action (if applicable) pub related_action: Option<NetworkedActionType>, } #[derive(Serialize, Deserialize, Clone, Debug)] pub enum ErrorCode { /// Invalid action InvalidAction = 1000, /// Invalid target InvalidTarget = 1001, /// Not your turn NotYourTurn = 1002, /// Not your priority NotYourPriority = 1003, /// Invalid mana payment InvalidManaPayment = 1004, /// Game rule violation GameRuleViolation = 1005, /// Connection error ConnectionError = 2000, /// Synchronization error SyncError = 2001, /// Internal server error InternalError = 9000, } }
This protocol specification provides a comprehensive framework for networked gameplay in our MTG Commander engine. It balances efficiency, security, and game rule enforcement while handling the unique requirements of Magic: The Gathering, such as hidden information, complex targeting, and state-based effects.
Bevy Replicon Integration
This guide explains how Rummage integrates with bevy_replicon to provide networked multiplayer functionality.
Table of Contents
- Introduction to Replicon
- Replicon Architecture
- Replication Setup
- Replicated Components
- Server Authority
- Client Prediction
- Deterministic RNG
- Network Events
- Optimizing Network Traffic
- Testing Networked Gameplay
- Troubleshooting
Introduction to Replicon
Bevy Replicon is a networking library for Bevy that provides entity and component replication between server and clients. Rummage uses Replicon to enable multiplayer Magic: The Gathering games over a network.
Replicon follows a client-server model where:
- The server has authority over game state
- Clients receive updates from the server
- Client inputs are sent to the server
- The server processes inputs and updates the game state
Replicon Architecture
The networking architecture in Rummage builds on Replicon's core features:
Core Concepts
- Replication: The process of synchronizing entities and components from server to clients
- Client Prediction: Clients predict the outcome of their actions while waiting for server confirmation
- Server Authority: The server is the ultimate authority on game state
- Rollback: The ability to roll back and reapply actions when prediction is incorrect
Plugin Integration
Rummage integrates Replicon through a dedicated plugin:
#![allow(unused)] fn main() { pub struct RummageNetworkPlugin; impl Plugin for RummageNetworkPlugin { fn build(&self, app: &mut App) { // Add Replicon's server and client plugins based on configuration app.add_plugins(RepliconPlugins) // Add Rummage-specific network resources .init_resource::<NetworkConfig>() .init_resource::<ClientConnectionManager>() // Register replication types .register_type::<CardPosition>() .register_type::<PlayerLife>() .register_type::<HandContents>() // ... and more component types // Add network-specific systems .add_systems(PreUpdate, connect_to_server.run_if(resource_exists::<ClientConfig>())) .add_systems(Update, ( handle_connection_events, process_player_actions, sync_game_state, )); } } }
Replication Setup
Setting up replication in Rummage involves several key steps:
Server Setup
#![allow(unused)] fn main() { fn setup_server(mut commands: Commands, config: Res<NetworkConfig>) { // Create the server let server = RenetServer::new(ConnectionConfig { protocol: ProtocolId::default(), server_channels_config: ServerChannelsConfig::default(), client_channels_config: ClientChannelsConfig::default(), authentication: ServerAuthentication::Unsecure, }); // Add server components commands.insert_resource(server); commands.insert_resource(IsServer(true)); // Initialize server game state commands.insert_resource(ServerGameState::default()); info!("Server started on port {}", config.server_port); } }
Client Setup
#![allow(unused)] fn main() { fn connect_to_client(mut commands: Commands, client_config: Res<ClientConfig>) { // Create the client let client = RenetClient::new(ConnectionConfig { protocol: ProtocolId::default(), server_channels_config: ServerChannelsConfig::default(), client_channels_config: ClientChannelsConfig::default(), authentication: ClientAuthentication::Unsecure, }); // Add client components commands.insert_resource(client); commands.insert_resource(IsServer(false)); info!("Client connecting to {}:{}", client_config.server_address, client_config.server_port); } }
Replicated Components
Components that need network synchronization must be marked with Replicon's Replicate
component:
#![allow(unused)] fn main() { // Card component that will be replicated #[derive(Component, Serialize, Deserialize, Clone, Debug, Reflect)] #[reflect(Component)] // Required for Replicon to reflect the component pub struct Card { pub id: String, pub name: String, pub card_type: CardType, // Other card properties... } // In your setup code, mark entities for replication fn setup_replication(mut commands: Commands) { // Spawn an entity with components that will be replicated commands.spawn(( Card { id: "some_card_id", name: "Card Name", card_type: CardType::Creature, // ... }, // Mark this component for replication Replicate, )); } }
Some entities may have components that should only exist on the server:
#![allow(unused)] fn main() { // ServerOnly component is not replicated to clients #[derive(Component)] pub struct ServerOnly { pub secret_data: String, } // ServerReplication marks a type as server-only commands.spawn(( Card { /* ... */ }, ServerOnly { secret_data: "hidden from clients" }, Replicate, ServerReplication, // This entity is only replicated from server to clients )); }
Server Authority
The server maintains authority over game state through several mechanisms:
Authoritative Systems
#![allow(unused)] fn main() { // This system only runs on the server fn process_game_actions( mut commands: Commands, mut action_events: EventReader<GameActionEvent>, mut game_state: ResMut<GameState>, is_server: Res<IsServer>, ) { // Only run on the server if !is_server.0 { return; } for action in action_events.iter() { // Process the action authoritatively match action { GameActionEvent::PlayCard { player_id, card_id, target } => { // Server implementation of playing a card // This will automatically be replicated to clients }, // Handle other actions... } } } }
Action Validation
#![allow(unused)] fn main() { fn validate_card_play( action: &PlayCardAction, player_state: &PlayerState, game_state: &GameState, ) -> Result<(), ActionError> { // Validate the player has the card in hand if !player_state.hand.contains(&action.card_id) { return Err(ActionError::InvalidCard); } // Validate the player has enough mana let card = game_state.cards.get(&action.card_id) .ok_or(ActionError::CardNotFound)?; if !player_state.can_pay_mana_cost(&card.mana_cost) { return Err(ActionError::InsufficientMana); } // Additional validation logic... Ok(()) } }
Client Prediction
To provide a responsive feel, clients can predict the outcome of actions while waiting for server confirmation:
#![allow(unused)] fn main() { fn client_predict_card_play( mut commands: Commands, mut action_events: EventReader<PlayCardAction>, mut game_state: ResMut<ClientGameState>, is_server: Res<IsServer>, ) { // Only run on clients if is_server.0 { return; } for action in action_events.iter() { // Make a prediction about what will happen if let Some(card) = game_state.player.hand.remove(&action.card_id) { // Create a predicted entity for the card on the battlefield commands.spawn(( card.clone(), PredictedEntity, // Mark as a prediction that might need correction BattlefieldCard { position: action.position }, // ... )); // Update predicted game state game_state.prediction_applied = true; // Send the action to the server for confirmation // ... } } } }
When server confirmation is received, predictions are validated or corrected:
#![allow(unused)] fn main() { fn handle_server_confirmation( mut commands: Commands, mut confirmation_events: EventReader<ServerConfirmationEvent>, mut predicted_query: Query<(Entity, &PredictedEntity)>, mut game_state: ResMut<ClientGameState>, ) { for confirmation in confirmation_events.iter() { if confirmation.action_id == game_state.last_prediction_id { if confirmation.success { // Remove the prediction marker as it was correct for (entity, _) in predicted_query.iter() { commands.entity(entity).remove::<PredictedEntity>(); } } else { // Prediction was wrong, remove predicted entities for (entity, _) in predicted_query.iter() { commands.entity(entity).despawn(); } // Server will send the correct state via replication game_state.prediction_applied = false; } } } } }
Deterministic RNG
For card games, deterministic random number generation is critical to ensure all clients and the server see the same outcome:
#![allow(unused)] fn main() { // Resource for synchronized RNG #[derive(Resource)] pub struct DeterministicRng { rng: StdRng, seed: u64, sequence: u64, } impl Default for DeterministicRng { fn default() -> Self { let seed = 12345; // In practice, use a seed from server Self { rng: StdRng::seed_from_u64(seed), seed, sequence: 0, } } } impl DeterministicRng { // Get a random value and advance the sequence pub fn get_random(&mut self) -> u32 { self.sequence += 1; self.rng.next_u32() } // Reset to a specific sequence point pub fn reset_to_sequence(&mut self, sequence: u64) { // Reseed with original seed self.rng = StdRng::seed_from_u64(self.seed); // Fast-forward to the specified sequence for _ in 0..sequence { self.rng.next_u32(); } self.sequence = sequence; } } }
Synchronizing RNG state:
#![allow(unused)] fn main() { // Server sends RNG state to clients fn sync_rng_state( mut sync_events: EventWriter<RngSyncEvent>, rng: Res<DeterministicRng>, is_server: Res<IsServer>, ) { if is_server.0 && rng.is_changed() { sync_events.send(RngSyncEvent { seed: rng.seed, sequence: rng.sequence, }); } } // Clients update their RNG state fn apply_rng_sync( mut sync_events: EventReader<RngSyncEvent>, mut rng: ResMut<DeterministicRng>, is_server: Res<IsServer>, ) { if !is_server.0 { for event in sync_events.iter() { rng.seed = event.seed; rng.reset_to_sequence(event.sequence); } } } }
Network Events
Rummage uses events to communicate between server and clients:
#![allow(unused)] fn main() { // Server to client events #[derive(Event, Serialize, Deserialize)] pub enum ServerToClientEvent { GameStateUpdate(GameStateSnapshot), PlayerJoined { player_id: u64, name: String }, PlayerLeft { player_id: u64 }, ChatMessage { player_id: u64, message: String }, GameAction { action_id: u64, action: GameAction }, } // Client to server events #[derive(Event, Serialize, Deserialize)] pub enum ClientToServerEvent { RequestAction { action_id: u64, action: GameAction }, ChatMessage { message: String }, Ready, Concede, } }
Handling these events:
#![allow(unused)] fn main() { fn handle_client_events( mut client_events: EventReader<ClientToServerEvent>, mut game_state: ResMut<GameState>, mut player_states: Query<&mut PlayerState>, is_server: Res<IsServer>, ) { if !is_server.0 { return; } for event in client_events.iter() { match event { ClientToServerEvent::RequestAction { action_id, action } => { // Process client action request // ... }, ClientToServerEvent::ChatMessage { message } => { // Broadcast chat to all clients // ... }, // Handle other events... } } } }
Optimizing Network Traffic
Efficient network usage is important for a smooth multiplayer experience:
Bandwidth Optimization
#![allow(unused)] fn main() { // Configure what components to replicate and how often fn configure_replication(mut config: ResMut<ReplicationConfig>) { // High-priority components update every frame config.set_frequency::<PlayerLife>(UpdateFrequency::EveryFrame); // Medium-priority components update less frequently config.set_frequency::<CardPosition>(UpdateFrequency::Every(2)); // Low-priority components update rarely config.set_frequency::<PlayerName>(UpdateFrequency::Every(60)); } }
Delta Compression
#![allow(unused)] fn main() { // Only send component changes, not full state fn optimize_card_updates( mut card_query: Query<&mut Card, Changed<Card>>, mut replicon: ResMut<RepliconServer>, ) { for mut card in card_query.iter_mut() { // Replicon will only send the changes // No need to do anything special here as Replicon // automatically detects and sends only changed components } } }
Testing Networked Gameplay
Testing multiplayer functionality is crucial:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn test_client_server_sync() { // Create a server app let mut server_app = App::new(); server_app .add_plugins(MinimalPlugins) .add_plugin(RepliconServerPlugin) .add_plugin(RummageNetworkPlugin) .insert_resource(NetworkConfig { server_port: 42424, max_clients: 4, }); // Create a client app let mut client_app = App::new(); client_app .add_plugins(MinimalPlugins) .add_plugin(RepliconClientPlugin) .add_plugin(RummageNetworkPlugin) .insert_resource(ClientConfig { server_address: "127.0.0.1".to_string(), server_port: 42424, }); // Simulate connection and game actions // ... // Verify client and server have synchronized state // ... } } }
Troubleshooting
Common Network Issues
Connection Failures
If clients can't connect to the server:
- Verify the server is running and listening on the correct port
- Check firewall settings
- Ensure the client is using the correct server address and port
- Check for network connectivity between the client and server
Desynchronization
If clients become desynchronized from the server:
- Check for non-deterministic behavior in game logic
- Ensure all RNG is using the deterministic RNG system
- Verify replicated components have proper Change detection
- Check for race conditions in event handling
High Latency
If game actions feel sluggish:
- Optimize the frequency of component replication
- Implement more client-side prediction
- Consider delta compression for large state changes
- Profile network traffic to identify bottlenecks
For complete examples of networked gameplay, see the Multiplayer Samples section.
Multiplayer Lobby System Documentation
This document serves as an index for all documentation related to Rummage's multiplayer lobby system for Commander format games. These documents cover the user interface, networking architecture, and implementation details for creating a robust and enjoyable multiplayer experience.
Overview and Architecture
- Lobby UI System Overview - High-level overview of the lobby UI architecture and flow
- Lobby Networking - Networking architecture for the lobby system
- Lobby Backend - Server-side implementation details
User Interface Components
- Lobby Browser UI - UI for browsing available game lobbies
- Lobby Detail UI - UI for the specific lobby view, player management, and ready-up mechanics
- Lobby Chat UI - Chat system implementation for lobby communication
- Lobby Deck Viewer - Deck and commander viewing UI
Gameplay and Departure Handling
- Game Departure Handling - How the system handles players leaving games
Implementation Guides
- Lobby Implementation Guide - Step-by-step guide for implementing lobby features
- Lobby Testing Guide - Testing strategies for the lobby system
Feature Highlights
Server List and Direct Connect
The lobby system supports two connection methods:
- Server List: Browse a list of available lobby servers
- Direct Connect: Connect directly to a specific host's IP
Lobby Browsing
The browser screen provides a comprehensive view of available lobbies with:
- Lobby name and host information
- Player count and maximum players
- Game format details
- Password protection indicator
- Filtering and sorting options
In-Lobby Features
Once in a lobby, players can:
- Chat with other players
- View player information
- Select a deck and indicate readiness
- View other players' commander cards and deck information
- Manage privacy settings for their decks
Host Controls
Lobby hosts have additional capabilities:
- Kick players
- Set lobby rules and restrictions
- Configure game settings
- Launch the game when all players are ready
Privacy and Deck Sharing
The system implements flexible privacy settings:
- Share just commander information
- Share basic deck statistics
- Share full decklist
- Customizable sharing options
Robust Departure Handling
The system gracefully handles various departure scenarios:
- Voluntary quits
- Being kicked by host
- Temporary disconnections with reconnection window
- Preservation of game state for departed players
Implementation Details
The lobby system is built using:
- Bevy ECS: Component-based architecture for UI and game state
- Bevy Replicon: Networking and state replication
- WebRTC: For efficient and reliable UDP-based communication
- Delta Compression: For efficient state updates
Getting Started
To get started with implementing or extending the lobby system, we recommend:
- Review the Lobby UI System Overview for a high-level understanding
- Examine the Lobby Networking document to understand the communication architecture
- Follow the step-by-step Lobby Implementation Guide
Future Enhancements
Planned enhancements for the lobby system include:
- Voice chat integration
- Advanced deck analysis tools
- Tournament mode
- Spectator support
- Enhanced moderation tools
Matchmaking
Friend System
Lobby Backend System
This section covers the server-side backend implementation of the lobby system for multiplayer Commander games.
Overview
The lobby backend handles player matchmaking, game creation, and transition to gameplay. It serves as the central coordination point for players before a game begins.
Components
Implementation
Detailed implementation of the lobby backend, including:
- Server architecture
- Player session management
- Game room creation and configuration
- Player queuing and matchmaking algorithms
- Ready state tracking
- Game initialization
Networking
Networking aspects of the lobby backend, including:
- Protocol details for lobby communication
- Message formats and serialization
- Connection management
- Authentication and session handling
- Error handling and recovery
- Security considerations
Integration
The lobby backend integrates with:
- Lobby UI for client-side display
- Chat System for pre-game communication
- Deck Viewer for deck selection and validation
- Gameplay Networking for transitioning to active games
Multiplayer Lobby Backend Implementation
This document focuses on the server-side implementation details of the lobby system for Rummage's multiplayer Commander games. It covers the networking architecture, server infrastructure, and data flow between clients and servers.
Table of Contents
- Architecture Overview
- Lobby Server Implementation
- Game Server Implementation
- Connection and Protocol
- Data Persistence
- Security Considerations
- Testing and Validation
- Deployment Considerations
Architecture Overview
The multiplayer system uses a dual-server architecture:
- Lobby Server: Manages lobbies, matchmaking, and initial connections
- Game Server: Handles the actual Commander gameplay after a match starts
┌────────────────┐
│ Lobby Server │
└───────┬────────┘
│
┌───────────────┼───────────────┐
│ │ │
┌───────┴─────┐ ┌───────┴─────┐ ┌───────┴─────┐
│ Game Server │ │ Game Server │ │ Game Server │
└─────────────┘ └─────────────┘ └─────────────┘
Components
- Lobby Manager: Tracks active lobbies and their states
- Session Manager: Handles player authentication and persistence
- Game Instance Factory: Creates and configures new game instances
- Message Broker: Routes communications between components
- Persistence Layer: Stores lobby and game data
Lobby Server Implementation
The Lobby Server is responsible for:
- Managing the list of available lobbies
- Handling player authentication
- Processing lobby creation, updates, and deletions
- Facilitating player chat and interactions in lobbies
- Initiating game sessions when a match starts
#![allow(unused)] fn main() { /// Main lobby server resource #[derive(Resource)] pub struct LobbyServer { /// Active lobbies indexed by ID pub lobbies: HashMap<String, LobbyData>, /// Connected clients pub clients: HashMap<ClientId, LobbyClientInfo>, /// Available game servers pub game_servers: Vec<GameServerInfo>, } /// Data structure for tracking a lobby #[derive(Clone, Debug)] pub struct LobbyData { /// Lobby information visible to players pub info: LobbyInfo, /// Detailed lobby settings pub settings: LobbySettings, /// Players in the lobby pub players: HashMap<String, LobbyPlayer>, /// Chat history pub chat_history: Vec<ChatMessage>, /// Creation timestamp pub created_at: f64, /// Last activity timestamp pub last_activity: f64, } /// Systems to handle lobby server operations pub fn handle_lobby_connections( mut server: ResMut<RepliconServer>, mut lobby_server: ResMut<LobbyServer>, mut connection_events: EventReader<ServerEvent>, time: Res<Time>, ) { for event in connection_events.read() { match event { ServerEvent::ClientConnected { client_id } => { // Add client to connected clients lobby_server.clients.insert(*client_id, LobbyClientInfo { client_id: *client_id, state: LobbyClientState::Connected, username: None, lobby_id: None, connected_at: time.elapsed_seconds(), last_activity: time.elapsed_seconds(), }); } ServerEvent::ClientDisconnected { client_id, reason } => { // Handle client disconnection if let Some(client_info) = lobby_server.clients.remove(client_id) { // If client was in a lobby, remove them if let Some(lobby_id) = client_info.lobby_id { if let Some(lobby) = lobby_server.lobbies.get_mut(&lobby_id) { // Remove player from lobby if let Some(username) = &client_info.username { lobby.players.remove(username); // Add system message about player leaving lobby.chat_history.push(ChatMessage { id: generate_uuid(), sender: "System".to_string(), is_system: true, content: format!("{} has left the lobby", username), timestamp: time.elapsed_seconds(), }); // Notify other players in the lobby notify_lobby_update(&mut server, &lobby_id, &lobby_server); // If lobby is empty or host left, handle lobby cleanup handle_potential_lobby_cleanup(&mut lobby_server, &lobby_id); } } } } } } } } /// Process incoming lobby actions pub fn process_lobby_requests( mut server: ResMut<RepliconServer>, mut lobby_server: ResMut<LobbyServer>, mut lobby_requests: EventReader<FromClient<LobbyRequest>>, time: Res<Time>, ) { for FromClient { client_id, event } in lobby_requests.read() { match event { LobbyRequest::ListLobbies(request) => { // Handle lobby list request let filtered_lobbies = filter_lobbies(&lobby_server.lobbies, &request.filters); let response = ServerListResponse { lobbies: filtered_lobbies, total_lobbies: lobby_server.lobbies.len(), }; // Send response to client server.send_message(*client_id, response); } LobbyRequest::CreateLobby(request) => { // Handle lobby creation request let lobby_id = generate_uuid(); let client_info = lobby_server.clients.get_mut(client_id).unwrap(); // Create new lobby let lobby = LobbyData { info: LobbyInfo { id: lobby_id.clone(), name: request.name.clone(), host_name: client_info.username.clone().unwrap_or_default(), player_count: 1, max_players: request.max_players, has_password: request.password.is_some(), format: request.format.clone(), restrictions: request.restrictions.clone(), description: request.description.clone(), }, settings: request.settings.clone(), players: HashMap::new(), chat_history: Vec::new(), created_at: time.elapsed_seconds(), last_activity: time.elapsed_seconds(), }; // Add host to the lobby let player = LobbyPlayer { name: client_info.username.clone().unwrap_or_default(), is_host: true, status: PlayerLobbyState::Joined, deck_info: None, }; let mut players = HashMap::new(); players.insert(player.name.clone(), player); lobby.players = players; // Add lobby to server lobby_server.lobbies.insert(lobby_id.clone(), lobby); // Update client info client_info.lobby_id = Some(lobby_id.clone()); client_info.state = LobbyClientState::InLobby; // Send response to client server.send_message(*client_id, CreateLobbyResponse { success: true, lobby_id: Some(lobby_id), error: None, }); } LobbyRequest::JoinLobby(request) => { // Handle join lobby request if let Some(lobby) = lobby_server.lobbies.get_mut(&request.lobby_id) { // Check if lobby is joinable if lobby.players.len() >= lobby.info.max_players { // Lobby is full server.send_message(*client_id, JoinLobbyResponse { success: false, failure_reason: Some("Lobby is full".to_string()), lobby_details: None, }); continue; } // Check password if required if lobby.info.has_password { // Verify password } // Add player to lobby let client_info = lobby_server.clients.get_mut(client_id).unwrap(); let player_name = client_info.username.clone().unwrap_or_default(); let player = LobbyPlayer { name: player_name.clone(), is_host: false, status: PlayerLobbyState::Joined, deck_info: None, }; lobby.players.insert(player_name, player); lobby.info.player_count = lobby.players.len(); lobby.last_activity = time.elapsed_seconds(); // Update client info client_info.lobby_id = Some(request.lobby_id.clone()); client_info.state = LobbyClientState::InLobby; // Add system message about player joining lobby.chat_history.push(ChatMessage { id: generate_uuid(), sender: "System".to_string(), is_system: true, content: format!("{} has joined the lobby", player_name), timestamp: time.elapsed_seconds(), }); // Notify all players in the lobby notify_lobby_update(&mut server, &request.lobby_id, &lobby_server); // Send response to joining client server.send_message(*client_id, JoinLobbyResponse { success: true, failure_reason: None, lobby_details: Some(convert_to_lobby_details(lobby)), }); } else { // Lobby not found server.send_message(*client_id, JoinLobbyResponse { success: false, failure_reason: Some("Lobby not found".to_string()), lobby_details: None, }); } } LobbyRequest::SendChat(request) => { // Handle chat message process_chat_message(client_id, request, &mut server, &mut lobby_server, &time); } LobbyRequest::UpdateStatus(request) => { // Handle player status update process_status_update(client_id, request, &mut server, &mut lobby_server, &time); } LobbyRequest::ViewDeck(request) => { // Handle deck view request process_deck_view_request(client_id, request, &mut server, &mut lobby_server); } LobbyRequest::LaunchGame(request) => { // Handle game launch request process_game_launch(client_id, request, &mut server, &mut lobby_server); } } } } }
Game Server Implementation
The Game Server handles the actual Commander gameplay after a match is initiated:
#![allow(unused)] fn main() { /// Main game server resource #[derive(Resource)] pub struct GameServer { /// Current game state pub game_state: Option<GameState>, /// Connected players pub players: HashMap<ClientId, Entity>, /// Whether this server is accepting new connections pub accepting_connections: bool, /// Game configuration pub config: GameConfig, } /// Start a new game instance pub fn start_game_instance( mut commands: Commands, mut server: ResMut<RepliconServer>, game_launch: Res<GameLaunchInfo>, ) { // Initialize game state let game_state = initialize_game_state(&game_launch); commands.insert_resource(game_state.clone()); // Configure server to accept connections from players commands.insert_resource(GameServer { game_state: Some(game_state), players: HashMap::new(), accepting_connections: true, config: game_launch.settings.clone(), }); // Start server on specified port server.start_listening(game_launch.port); // Set up turn tracking commands.insert_resource(TurnManager::new()); // Set up zones for cards commands.insert_resource(ZoneManager::new()); // Notify lobby server that this game instance is ready notify_lobby_server_game_ready(game_launch.lobby_id.clone(), game_launch.port); } /// Transfer players from lobby to game pub fn transfer_players_to_game( mut server: ResMut<RepliconServer>, game_server: Res<GameServer>, lobby_connection: Res<LobbyServerConnection>, game_launch: Res<GameLaunchInfo>, ) { // Notify all players in the lobby that the game is ready let connection_details = GameConnectionDetails { server_address: get_server_address(), game_id: game_launch.game_id.clone(), connection_token: generate_connection_tokens(&game_launch.players), }; let notification = GameLaunchNotification { connection_details, players: game_launch.players.clone(), settings: game_launch.settings.clone(), }; // Send notification to lobby server to distribute to players lobby_connection.send_message(LobbyServerMessage::GameReady(notification)); } }
Connection and Protocol
The networking protocol uses WebRTC for UDP-based communication with reliability layers:
#![allow(unused)] fn main() { /// Connection protocol constants pub mod protocol { /// Protocol version pub const PROTOCOL_VERSION: &str = "1.0.0"; /// Maximum message size in bytes pub const MAX_MESSAGE_SIZE: usize = 1024 * 64; /// Heartbeat interval in seconds pub const HEARTBEAT_INTERVAL: f32 = 1.0; /// Connection timeout in seconds pub const CONNECTION_TIMEOUT: f32 = 5.0; } /// Initialize the networking protocol pub fn init_networking(app: &mut App) { app .add_plugins(RepliconPlugin) .add_systems(Startup, setup_network_config) .add_systems(PreUpdate, ( process_connection_events, handle_protocol_messages, )) .add_systems(Update, ( send_heartbeats, check_timeouts, )); } /// Set up network configuration fn setup_network_config(mut commands: Commands) { commands.insert_resource(RepliconConfig { max_message_size: protocol::MAX_MESSAGE_SIZE, max_message_channel_count: 3, ..default() }); } /// Channel types for different message priorities #[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)] pub enum NetworkChannel { /// Reliable ordered channel for important messages Reliable, /// Unreliable channel for position updates Unreliable, /// Ordered but can drop messages for less critical state ReliableUnordered, } }
Network Optimization
To handle the complexity of Commander games, we implement several optimization techniques:
- Delta Compression: Only send changes to game state
- Interest Management: Only sync relevant parts of the game state to each client
- Batched Updates: Collect multiple updates and send them together
- Prioritized Synchronization: Critical game events take priority over visual updates
#![allow(unused)] fn main() { /// Delta encoder for game state changes pub struct DeltaEncoder { /// Previous game state hash previous_hash: u64, /// Component change tracking component_changes: HashMap<Entity, Vec<ComponentChange>>, } impl DeltaEncoder { /// Encode delta changes between states pub fn encode_delta(&mut self, current_state: &GameState) -> DeltaPacket { // Calculate changes since previous state // ... DeltaPacket { base_hash: self.previous_hash, entities_added: vec![], entities_removed: vec![], component_changes: self.component_changes.clone(), } } } }
Data Persistence
The lobby server persists certain data to provide continuity:
- User Accounts: Player profiles and authentication
- Lobby Templates: Saved lobby configurations
- Match History: Record of played games
- Deck Statistics: Anonymized deck performance data
#![allow(unused)] fn main() { /// Data persistence manager pub struct PersistenceManager { /// Database connection db_connection: DbConnection, /// Cache for frequently accessed data cache: LruCache<String, CachedData>, } impl PersistenceManager { /// Save lobby to database pub async fn save_lobby(&self, lobby: &LobbyData) -> Result<(), DbError> { // Serialize and store lobby data // ... Ok(()) } /// Load lobby from database pub async fn load_lobby(&self, lobby_id: &str) -> Result<Option<LobbyData>, DbError> { // Retrieve and deserialize lobby data // ... Ok(None) } /// Record match results pub async fn record_match_result(&self, results: &MatchResults) -> Result<(), DbError> { // Store match results for history and statistics // ... Ok(()) } } }
Security Considerations
The multiplayer system implements several security measures:
- Authentication: Verify player identities
- Authorization: Control access to lobbies and games
- Input Validation: Sanitize all player input
- Rate Limiting: Prevent spam and DoS attacks
- Encryption: Secure sensitive communications
#![allow(unused)] fn main() { /// Security module for the lobby system pub mod security { /// Validate player input pub fn validate_player_input(input: &str) -> bool { // Check for malicious content // ... true } /// Rate limit tracker pub struct RateLimiter { /// Action counts per client client_actions: HashMap<ClientId, Vec<(f64, ActionType)>>, } impl RateLimiter { /// Check if action is allowed pub fn check_rate_limit(&mut self, client_id: ClientId, action: ActionType, time: f64) -> bool { // Implement rate limiting logic // ... true } } /// Encrypt sensitive data pub fn encrypt_data(data: &[u8], key: &[u8]) -> Vec<u8> { // Encryption implementation // ... Vec::new() } } }
Testing and Validation
The lobby system includes comprehensive testing:
- Unit Tests: Verify individual component behavior
- Integration Tests: Test component interactions
- Load Tests: Ensure system can handle many concurrent lobbies
- Latency Simulation: Test under various network conditions
- Security Tests: Verify system resilience against attacks
#![allow(unused)] fn main() { /// Test suite for the lobby system #[cfg(test)] mod tests { use super::*; /// Test lobby creation and joining #[test] fn test_lobby_lifecycle() { // Set up test environment let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(LobbyPlugin); // Create a lobby // ... // Join the lobby // ... // Verify state // ... } /// Test chat functionality #[test] fn test_chat_system() { // Set up test environment // ... // Send chat messages // ... // Verify delivery // ... } /// Test game launch process #[test] fn test_game_launch() { // Set up test environment // ... // Ready up players // ... // Launch game // ... // Verify transition // ... } } }
Deployment Considerations
The lobby system supports various deployment scenarios:
- Self-Hosted: Players can host their own servers
- Dedicated Servers: Centralized infrastructure
- Hybrid Model: Official servers plus community hosting
- Cloud Deployment: Scalable containers for peak times
#![allow(unused)] fn main() { /// Deployment configuration pub struct DeploymentConfig { /// Server discovery method pub discovery: ServerDiscoveryMethod, /// Server capacity pub capacity: ServerCapacity, /// Geographic region pub region: String, /// Auto-scaling settings pub scaling: Option<ScalingConfig>, } /// Server capacity configuration pub struct ServerCapacity { /// Maximum concurrent lobbies pub max_lobbies: usize, /// Maximum concurrent games pub max_games: usize, /// Maximum players per server pub max_players: usize, } /// Auto-scaling configuration pub struct ScalingConfig { /// Minimum number of instances pub min_instances: usize, /// Maximum number of instances pub max_instances: usize, /// Scale up threshold (% utilization) pub scale_up_threshold: f32, /// Scale down threshold (% utilization) pub scale_down_threshold: f32, } }
This document provides a comprehensive overview of the server-side implementation for the multiplayer lobby system, including the necessary architectural components and security considerations.
Multiplayer Lobby System
This document outlines the networking architecture for the Commander format multiplayer lobby system. The lobby system enables players to discover, join, and configure games before starting a match.
Table of Contents
- System Overview
- Lobby Discovery
- Lobby Information and Browsing
- Joining and Managing Lobbies
- Chat System
- Ready-Up Mechanism
- Deck and Commander Viewing
- Game Launch
- Connection Protocol
- Implementation Details
System Overview
The lobby system serves as the pre-game matchmaking component of the multiplayer experience. It allows players to:
- Browse available game lobbies
- Create new lobbies with custom settings
- Join existing lobbies
- Chat with other players
- View other players' decks and commanders
- Ready up for the game
- Launch into a Commander game once all players are ready
#![allow(unused)] fn main() { /// The main states of the lobby system #[derive(States, Debug, Clone, Copy, Eq, PartialEq, Hash)] pub enum LobbyState { /// Lobby browser showing available games Browser, /// Inside a specific lobby InLobby, /// Transitioning to game LaunchingGame, } /// Resource tracking the active lobby connection #[derive(Resource, Default)] pub struct LobbyConnection { /// ID of the connected lobby or None if browsing pub current_lobby_id: Option<String>, /// Whether the player is the host of the current lobby pub is_host: bool, /// The server address this lobby is hosted on pub server_address: Option<String>, } }
Lobby Discovery
Players can discover lobbies through two primary methods:
- Server List: Connect to a lobby server that hosts multiple lobbies
- Direct IP: Connect directly to a specific lobby host
#![allow(unused)] fn main() { /// Methods for discovering lobbies #[derive(Debug, Clone, PartialEq, Eq)] pub enum LobbyDiscoveryMethod { /// Connect to a lobby server ServerList(String), // Server address /// Connect directly to a host DirectIp(String), // IP address and port } /// Server list request message #[derive(Serialize, Deserialize, Clone, Debug)] pub struct ServerListRequest { /// Client version pub version: String, /// Optional filter parameters pub filters: Option<LobbyFilters>, } /// Server list response message #[derive(Serialize, Deserialize, Clone, Debug)] pub struct ServerListResponse { /// List of available lobbies pub lobbies: Vec<LobbyInfo>, /// Total number of lobbies on the server pub total_lobbies: usize, } }
Lobby Browser UI
The lobby browser presents a list of available lobbies with key information:
- Lobby name
- Host name
- Current player count / maximum players
- Commander format details (standard, cEDH, etc.)
- Special restrictions or rules
- Whether the lobby is password-protected
Lobby Information and Browsing
Lobbies include various pieces of information for players to browse:
#![allow(unused)] fn main() { /// Information about a lobby visible in the browser #[derive(Serialize, Deserialize, Clone, Debug)] pub struct LobbyInfo { /// Unique identifier for the lobby pub id: String, /// Displayed name of the lobby pub name: String, /// Host player's name pub host_name: String, /// Current number of players pub player_count: usize, /// Maximum allowed players pub max_players: usize, /// Whether the lobby is password protected pub has_password: bool, /// Format information pub format: CommanderFormat, /// Game rules and restrictions pub restrictions: GameRestrictions, /// Brief description provided by the host pub description: Option<String>, } /// Commander format details #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub enum CommanderFormat { /// Standard Commander rules Standard, /// Competitive EDH CEDH, /// Commander Variant (e.g., Brawl, Oathbreaker) Variant(String), /// Custom rule set Custom, } /// Game restrictions and rule modifications #[derive(Serialize, Deserialize, Clone, Debug, Default)] pub struct GameRestrictions { /// Card pool restrictions (e.g., budget, no infinites) pub card_pool: Vec<String>, /// Deck construction rules pub deck_rules: Vec<String>, /// House rules for gameplay pub game_rules: Vec<String>, } }
Joining and Managing Lobbies
When a player selects a lobby, they can view detailed information before joining:
#![allow(unused)] fn main() { /// Detailed lobby information shown when selected #[derive(Serialize, Deserialize, Clone, Debug)] pub struct LobbyDetails { /// Basic lobby info pub info: LobbyInfo, /// Detailed description pub full_description: String, /// Current players in the lobby pub players: Vec<LobbyPlayerInfo>, /// Expected game duration pub estimated_duration: Option<String>, /// Additional custom settings pub custom_settings: HashMap<String, String>, } /// Join request message #[derive(Serialize, Deserialize, Clone, Debug)] pub struct JoinLobbyRequest { /// Lobby to join pub lobby_id: String, /// Player name pub player_name: String, /// Password if required pub password: Option<String>, } /// Join response message #[derive(Serialize, Deserialize, Clone, Debug)] pub struct JoinLobbyResponse { /// Whether the join was successful pub success: bool, /// Reason for failure if unsuccessful pub failure_reason: Option<String>, /// Lobby details if successful pub lobby_details: Option<LobbyDetails>, } }
Chat System
The lobby includes a chat system to allow players to communicate:
#![allow(unused)] fn main() { /// Chat message in a lobby #[derive(Serialize, Deserialize, Clone, Debug)] pub struct ChatMessage { /// Message ID for tracking pub id: String, /// Sender of the message pub sender: String, /// Whether the message is from the system pub is_system: bool, /// Message content pub content: String, /// Timestamp pub timestamp: f64, } /// Chat message request #[derive(Serialize, Deserialize, Clone, Debug)] pub struct SendChatRequest { /// Lobby ID pub lobby_id: String, /// Message content pub content: String, } }
The chat system should support:
- Player-to-player messages
- System announcements
- Emoji/reactions
- Message history
Ready-Up Mechanism
Players need to "ready up" before a game can start:
#![allow(unused)] fn main() { /// Player state in the lobby #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub enum PlayerLobbyState { /// Just joined, not ready Joined, /// Selecting deck SelectingDeck, /// Ready with deck selected Ready, } /// Ready status update #[derive(Serialize, Deserialize, Clone, Debug)] pub struct ReadyStatusUpdate { /// Player changing status pub player_name: String, /// New status pub status: PlayerLobbyState, /// Selected deck info if ready pub deck_info: Option<DeckInfo>, } }
The host can only start the game when all players are in the Ready
state.
Deck and Commander Viewing
Players can view each other's decks and commanders:
#![allow(unused)] fn main() { /// Basic deck information #[derive(Serialize, Deserialize, Clone, Debug)] pub struct DeckInfo { /// Deck name pub name: String, /// Commander card pub commander: CommanderInfo, /// Partner commander if applicable pub partner: Option<CommanderInfo>, /// Deck color identity pub colors: Vec<String>, /// Card count pub card_count: usize, /// Average mana value pub avg_mana_value: f32, /// Deck power level (1-10) pub power_level: u8, /// Whether to share full decklist pub share_decklist: bool, } /// Commander card information #[derive(Serialize, Deserialize, Clone, Debug)] pub struct CommanderInfo { /// Card name pub name: String, /// Mana cost pub mana_cost: String, /// Card type pub type_line: String, /// Rules text pub text: String, /// Power/toughness if applicable pub power_toughness: Option<String>, /// Card image URI pub image_uri: Option<String>, } /// Request to view a full decklist #[derive(Serialize, Deserialize, Clone, Debug)] pub struct DeckViewRequest { /// Player whose deck to view pub player_name: String, } /// Full decklist response #[derive(Serialize, Deserialize, Clone, Debug)] pub struct DeckViewResponse { /// Basic deck info pub info: DeckInfo, /// Full card list if shared pub card_list: Option<Vec<CardInfo>>, /// Reason if not shared pub not_shared_reason: Option<String>, } }
Game Launch
When all players are ready, the host can launch the game:
#![allow(unused)] fn main() { /// Game launch request (host only) #[derive(Serialize, Deserialize, Clone, Debug)] pub struct LaunchGameRequest { /// Lobby ID pub lobby_id: String, } /// Game launch notification to all players #[derive(Serialize, Deserialize, Clone, Debug)] pub struct GameLaunchNotification { /// Game connection details pub connection_details: GameConnectionDetails, /// Final player list pub players: Vec<LobbyPlayerInfo>, /// Game settings pub settings: GameSettings, } /// Connection details for the actual game #[derive(Serialize, Deserialize, Clone, Debug)] pub struct GameConnectionDetails { /// Game server address pub server_address: String, /// Game ID pub game_id: String, /// Connection token pub connection_token: String, } }
Connection Protocol
The lobby system uses a reliable TCP connection for all communications:
- Player connects to a lobby server
- Authentication (if required)
- Request lobby list
- Select and join a lobby
- Participate in lobby activities
- Ready up when prepared
- Transition to game when launched
Connection States
#![allow(unused)] fn main() { /// Connection state machine #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LobbyConnectionState { /// Not connected Disconnected, /// Attempting to connect Connecting, /// Connected and authenticated Connected, /// Browsing available lobbies Browsing, /// Inside a specific lobby InLobby, /// Preparing to launch game PreLaunch, /// Transitioning to game Launching, /// Connection error Error, } }
Implementation Details
The lobby system will be implemented using Bevy's ECS architecture with these key components:
Lobby Browser Scene
#![allow(unused)] fn main() { pub fn setup_lobby_browser( mut commands: Commands, asset_server: Res<AssetServer>, lobbies: Res<AvailableLobbies>, ) { // Main container commands .spawn(( Node { width: Val::Percent(100.0), height: Val::Percent(100.0), flex_direction: FlexDirection::Column, ..default() }, LobbyBrowserUI, )) .with_children(|parent| { // Header parent.spawn(Node { width: Val::Percent(100.0), height: Val::Px(80.0), justify_content: JustifyContent::SpaceBetween, align_items: AlignItems::Center, padding: UiRect::all(Val::Px(20.0)), ..default() }).with_children(|header| { // Title header.spawn(Text2d { text: "Multiplayer Lobbies".into(), // Add styling... }); // Controls (Refresh, Create Lobby, etc.) header.spawn(Node { width: Val::Auto, height: Val::Px(40.0), flex_direction: FlexDirection::Row, ..default() }).with_children(|controls| { // Refresh button // Create lobby button // Direct connect field }); }); // Lobby list parent.spawn(Node { width: Val::Percent(100.0), height: Val::Percent(70.0), flex_direction: FlexDirection::Column, overflow: Overflow::Scroll, ..default() }).with_children(|list| { // Render each lobby as a selectable item for lobby in &lobbies.list { create_lobby_list_item(list, lobby, &asset_server); } }); // Details panel parent.spawn(Node { width: Val::Percent(100.0), height: Val::Percent(30.0), ..default() }).with_children(|details| { // Show selected lobby details // Join button }); }); } /// System to handle refreshing the lobby list pub fn refresh_lobby_list( mut lobby_query: EventReader<RefreshLobbyListEvent>, mut connection: ResMut<LobbyConnection>, mut lobbies: ResMut<AvailableLobbies>, ) { for _ in lobby_query.read() { // Send request to server // Handle response and update lobbies.list } } }
Lobby Detail Scene
#![allow(unused)] fn main() { pub fn setup_lobby_detail( mut commands: Commands, asset_server: Res<AssetServer>, lobby_details: Res<CurrentLobbyDetails>, ) { // Main container commands .spawn(( Node { width: Val::Percent(100.0), height: Val::Percent(100.0), flex_direction: FlexDirection::Row, ..default() }, LobbyDetailUI, )) .with_children(|parent| { // Left panel (player list and details) parent.spawn(Node { width: Val::Percent(30.0), height: Val::Percent(100.0), flex_direction: FlexDirection::Column, ..default() }).with_children(|left_panel| { // Lobby info // Player list // Ready button }); // Center panel (chat) parent.spawn(Node { width: Val::Percent(40.0), height: Val::Percent(100.0), flex_direction: FlexDirection::Column, ..default() }).with_children(|center_panel| { // Chat history // Chat input }); // Right panel (deck viewer) parent.spawn(Node { width: Val::Percent(30.0), height: Val::Percent(100.0), flex_direction: FlexDirection::Column, ..default() }).with_children(|right_panel| { // Selected deck info // Commander view // Deck stats }); }); } /// System to send chat messages pub fn send_chat_message( mut chat_events: EventReader<SendChatEvent>, mut connection: ResMut<LobbyConnection>, ) { for event in chat_events.read() { // Format and send chat message to server } } /// System to update player ready status pub fn update_ready_status( mut ready_events: EventReader<ReadyStatusEvent>, mut connection: ResMut<LobbyConnection>, ) { for event in ready_events.read() { // Update local state // Send ready status to server } } }
Game Launch
#![allow(unused)] fn main() { pub fn launch_game( mut launch_events: EventReader<LaunchGameEvent>, mut connection: ResMut<LobbyConnection>, mut next_state: ResMut<NextState<GameMenuState>>, ) { for event in launch_events.read() { // Handle game launch // Transition to Loading state next_state.set(GameMenuState::Loading); } } }
Systems and Resources
The lobby system will use these key Bevy systems and resources:
#![allow(unused)] fn main() { /// Plugin to register all lobby-related systems pub struct LobbyPlugin; impl Plugin for LobbyPlugin { fn build(&self, app: &mut App) { app // States .add_state::<LobbyState>() // Resources .init_resource::<LobbyConnection>() .init_resource::<AvailableLobbies>() .init_resource::<CurrentLobbyDetails>() .init_resource::<ChatHistory>() // Events .add_event::<RefreshLobbyListEvent>() .add_event::<JoinLobbyEvent>() .add_event::<LeaveLobbyEvent>() .add_event::<SendChatEvent>() .add_event::<ReadyStatusEvent>() .add_event::<ViewDeckEvent>() .add_event::<LaunchGameEvent>() // Systems .add_systems(OnEnter(LobbyState::Browser), setup_lobby_browser) .add_systems(OnExit(LobbyState::Browser), cleanup_lobby_browser) .add_systems(OnEnter(LobbyState::InLobby), setup_lobby_detail) .add_systems(OnExit(LobbyState::InLobby), cleanup_lobby_detail) .add_systems(Update, ( handle_lobby_connections, refresh_lobby_list, process_lobby_messages, ).run_if(in_state(LobbyState::Browser)) ) .add_systems(Update, ( process_lobby_messages, send_chat_message, update_ready_status, handle_deck_viewing, launch_game, ).run_if(in_state(LobbyState::InLobby)) ); } } }
Integration with Main Menu
The multiplayer button in the main menu will trigger a transition to the lobby browser:
#![allow(unused)] fn main() { pub fn menu_action( mut interaction_query: Query< (&Interaction, &MenuButtonAction, &mut BackgroundColor), (Changed<Interaction>, With<Button>), >, mut next_state: ResMut<NextState<GameMenuState>>, mut lobby_state: ResMut<NextState<LobbyState>>, mut exit: EventWriter<bevy::app::AppExit>, ) { for (interaction, action, mut color) in &mut interaction_query { if *interaction == Interaction::Pressed { match action { MenuButtonAction::Multiplayer => { // Transition to lobby browser lobby_state.set(LobbyState::Browser); // Note: We might need a new GameMenuState for multiplayer } // Other actions... } } } } }
This document provides a comprehensive overview of the multiplayer lobby system design, including the necessary components and systems to implement it within the Bevy ECS architecture. The implementation details can be expanded as needed during development.
Lobby Chat System
This section covers the chat functionality available in the game lobby, allowing players to communicate before a game begins.
Overview
The lobby chat system provides text-based communication between players in the lobby, supporting both global chat and game room-specific discussions.
Components
UI
Details of the chat user interface, including:
- Chat window layout and design
- Message display formatting
- Input mechanisms
- Notification systems
- Emoji and shortcut support
- Chat history and scrollback
Features
- Global lobby chat for all connected players
- Game room-specific chat for players in the same game
- Basic moderation features
- Friend-to-friend direct messages
- System announcements and notifications
- Chat history persistence
Integration
The lobby chat system integrates with:
- Lobby Backend for message routing
- Lobby UI for display integration
- In-game chat functionality for consistent user experience
- Friend System for direct messaging
Lobby Chat System UI
This document details the chat system UI used in multiplayer lobbies, which allows players to communicate before starting a Commander game. The chat system is crucial for discussing deck power levels, house rules, and general coordination.
Table of Contents
Overview
The chat system occupies the central panel of the lobby detail screen, providing a real-time communication channel between all players in the lobby. It supports text messages, system notifications, and special formatting for enhanced communication.
┌───────────────────────────────────┐
│ Chat Panel │
├───────────────────────────────────┤
│ │
│ [System] Player2 has joined │
│ │
│ Player1: Hello everyone! │
│ │
│ Player2: Hey, what power level │
│ are we playing at? │
│ │
│ Player1: Casual, around 6-7 │
│ │
│ [System] Player3 has joined │
│ │
│ Player3: I have a deck that │
│ should work for that │
│ │
│ │
├───────────────────────────────────┤
│ ┌─────────────────────────┐ ┌──┐ │
│ │ Type a message... │ │▶ │ │
│ └─────────────────────────┘ └──┘ │
└───────────────────────────────────┘
UI Components
The chat UI consists of several key components:
Message Display Area
- Scrollable container for message history
- Message bubbles with sender identification
- System messages with distinctive styling
- Timestamp indicators
- Auto-scrolling to newest messages
Input Area
- Text input field
- Send button
- Character counter
- Emoji selector
Additional Controls
- Chat options button (opens chat settings)
- Notification indicators for new messages
- Mentions highlighting
Message Types
The chat system supports various message types:
#![allow(unused)] fn main() { /// Types of messages in the lobby chat #[derive(Clone, Debug, PartialEq, Eq)] pub enum MessageType { /// Regular player chat message Chat, /// System notification System, /// Direct message to specific player(s) DirectMessage, /// Game announcement (like deck selection) Announcement, /// Error message Error, } /// Chat message component #[derive(Component, Clone, Debug)] pub struct ChatMessage { /// Unique identifier pub id: String, /// Sender name (if applicable) pub sender: Option<String>, /// Message content pub content: String, /// Type of message pub message_type: MessageType, /// Timestamp when message was sent pub timestamp: f64, /// Players mentioned in the message pub mentions: Vec<String>, } }
Features
Real-time Updates
The chat system provides real-time message delivery with visual indicators:
#![allow(unused)] fn main() { /// System to add new messages to the chat fn add_chat_message( mut commands: Commands, mut message_events: EventReader<NewChatMessageEvent>, mut chat_state: ResMut<ChatState>, time: Res<Time>, asset_server: Res<AssetServer>, ) { for event in message_events.read() { // Create the new message entity let message_entity = commands .spawn(ChatMessage { id: generate_uuid(), sender: event.sender.clone(), content: event.content.clone(), message_type: event.message_type.clone(), timestamp: time.elapsed_seconds(), mentions: find_mentions(&event.content), }) .id(); // Add to message history chat_state.messages.push(message_entity); // Trim history if too long if chat_state.messages.len() > MAX_CHAT_HISTORY { if let Some(old_message) = chat_state.messages.pop_front() { commands.entity(old_message).despawn(); } } // Play notification sound if appropriate if needs_notification(&event.message_type, &event.mentions) { commands.spawn(AudioSource { source: asset_server.load("sounds/chat_notification.ogg"), ..default() }); } } } }
Message Formatting
Players can use basic formatting in their messages:
- Bold text: Using
**text**
syntax - Italics: Using
*text*
syntax - Code blocks: Using
code
syntax - Links: URLs are automatically detected and made clickable
#![allow(unused)] fn main() { /// Process message formatting fn process_message_formatting( mut message_query: Query<(&mut Text, &ChatMessage), Added<ChatMessage>>, ) { for (mut text, message) in &mut message_query { let formatted_content = format_message_content(&message.content); // Apply formatting to text component text.sections[0].value = formatted_content; // Apply styling based on message type match message.message_type { MessageType::System => { text.sections[0].style.color = SYSTEM_MESSAGE_COLOR; } MessageType::Error => { text.sections[0].style.color = ERROR_MESSAGE_COLOR; } MessageType::Announcement => { text.sections[0].style.color = ANNOUNCEMENT_COLOR; } _ => { // Regular chat styling } } } } }
Mentions
Players can mention other players with the @username
syntax:
#![allow(unused)] fn main() { /// Find and extract mentions from message content fn find_mentions(content: &str) -> Vec<String> { let mut mentions = Vec::new(); // Regular expression to find @mentions let re = Regex::new(r"@(\w+)").unwrap(); for capture in re.captures_iter(content) { if let Some(username) = capture.get(1) { mentions.push(username.as_str().to_string()); } } mentions } /// Highlight mentions in messages fn highlight_mentions( mut message_query: Query<(&mut Text, &ChatMessage), Added<ChatMessage>>, local_player: Res<LocalPlayer>, ) { for (mut text, message) in &mut message_query { // Check if local player is mentioned if message.mentions.contains(&local_player.name) { // Highlight the entire message text.sections[0].style.color = MENTION_HIGHLIGHT_COLOR; } } } }
Chat Filtering
The system includes filtering options for inappropriate content:
#![allow(unused)] fn main() { /// Filter message content for inappropriate language fn filter_message_content(content: &str) -> String { let mut filtered = content.to_string(); // Apply word filters for (pattern, replacement) in INAPPROPRIATE_WORDS.iter() { let re = Regex::new(pattern).unwrap(); filtered = re.replace_all(&filtered, replacement).to_string(); } filtered } }
Notifications
Players receive notifications for new messages, especially mentions:
#![allow(unused)] fn main() { /// System to handle chat notifications fn update_chat_notifications( message_query: Query<&ChatMessage, Added<ChatMessage>>, mut notification_state: ResMut<NotificationState>, local_player: Res<LocalPlayer>, lobby_state: Res<LobbyState>, ) { for message in &message_query { // Check if the player is mentioned if message.mentions.contains(&local_player.name) { notification_state.add_notification(NotificationType::ChatMention { sender: message.sender.clone().unwrap_or_default(), preview: truncate_message(&message.content, 30), }); } // If chat is not focused, increment unread counter if !lobby_state.is_chat_focused { notification_state.unread_chat_messages += 1; } } } }
Implementation
The chat system is implemented using Bevy's ECS architecture:
Chat Panel Setup
#![allow(unused)] fn main() { /// Set up the chat panel UI pub fn setup_chat_panel( parent: &mut ChildBuilder, asset_server: &Res<AssetServer>, ) { // Chat panel container parent .spawn(Node { width: Val::Percent(40.0), height: Val::Percent(100.0), flex_direction: FlexDirection::Column, padding: UiRect::all(Val::Px(10.0)), ..default() }) .with_children(|chat_panel| { // Chat header chat_panel.spawn(Node { width: Val::Percent(100.0), height: Val::Px(30.0), justify_content: JustifyContent::Center, align_items: AlignItems::Center, margin: UiRect::bottom(Val::Px(10.0)), ..default() }).with_children(|header| { header.spawn(Text2d { text: "CHAT".into(), font: asset_server.load("fonts/FiraSans-Bold.ttf"), font_size: 18.0, color: Color::WHITE, }); }); // Message display area chat_panel .spawn(( Node { width: Val::Percent(100.0), height: Val::Percent(85.0), flex_direction: FlexDirection::Column, overflow: Overflow::Scroll, ..default() }, ChatMessageContainer, )) .with_children(|messages| { // Messages will be spawned here dynamically }); // Input area chat_panel .spawn(Node { width: Val::Percent(100.0), height: Val::Px(40.0), flex_direction: FlexDirection::Row, margin: UiRect::top(Val::Px(10.0)), align_items: AlignItems::Center, ..default() }) .with_children(|input_area| { // Text input field input_area .spawn(( Node { width: Val::Percent(85.0), height: Val::Percent(100.0), padding: UiRect::all(Val::Px(5.0)), ..default() }, BackgroundColor(Color::rgba(0.1, 0.1, 0.1, 0.7)), BorderColor(Color::rgba(0.5, 0.5, 0.5, 0.7)), Outline::new(Val::Px(1.0)), ChatInputField, )) .with_children(|input| { input.spawn(( Text2d { text: "Type a message...".into(), font: asset_server.load("fonts/FiraSans-Regular.ttf"), font_size: 14.0, color: Color::rgba(0.7, 0.7, 0.7, 1.0), }, ChatInputPlaceholder, )); }); // Send button input_area .spawn(( Button, Node { width: Val::Percent(15.0), height: Val::Percent(100.0), justify_content: JustifyContent::Center, align_items: AlignItems::Center, margin: UiRect::left(Val::Px(5.0)), ..default() }, BackgroundColor(Color::rgba(0.2, 0.4, 0.8, 0.7)), ChatSendButton, )) .with_children(|button| { button.spawn(Text2d { text: "▶".into(), font: asset_server.load("fonts/FiraSans-Bold.ttf"), font_size: 18.0, color: Color::WHITE, }); }); }); }); } }
Message Rendering
#![allow(unused)] fn main() { /// System to render chat messages fn render_chat_messages( mut commands: Commands, asset_server: Res<AssetServer>, chat_state: Res<ChatState>, mut message_container_query: Query<Entity, With<ChatMessageContainer>>, message_query: Query<(Entity, &ChatMessage)>, time: Res<Time>, ) { if chat_state.is_dirty { // Clear existing messages if let Ok(container_entity) = message_container_query.get_single_mut() { commands.entity(container_entity).despawn_descendants(); // Re-spawn all messages commands.entity(container_entity).with_children(|parent| { for &message_entity in &chat_state.messages { if let Ok((_, message)) = message_query.get(message_entity) { spawn_message_ui(parent, message, &asset_server, time.elapsed_seconds()); } } }); } // Mark as clean chat_state.is_dirty = false; } } /// Spawn a single chat message UI element fn spawn_message_ui( parent: &mut ChildBuilder, message: &ChatMessage, asset_server: &Res<AssetServer>, current_time: f64, ) { // Calculate relative time for display let time_ago = format_time_ago(current_time - message.timestamp); // Message container parent .spawn(( Node { width: Val::Percent(100.0), height: Val::Auto, flex_direction: FlexDirection::Column, margin: UiRect::bottom(Val::Px(5.0)), padding: UiRect::all(Val::Px(5.0)), ..default() }, BackgroundColor(get_message_background_color(&message.message_type)), BorderColor(get_message_border_color(&message.message_type)), Outline::new(Val::Px(1.0)), RenderChatMessage(message.id.clone()), )) .with_children(|message_container| { // Header with sender and timestamp message_container .spawn(Node { width: Val::Percent(100.0), height: Val::Auto, flex_direction: FlexDirection::Row, justify_content: JustifyContent::SpaceBetween, ..default() }) .with_children(|header| { // Sender name if let Some(sender) = &message.sender { header.spawn(Text2d { text: format!("{}", sender), font: asset_server.load("fonts/FiraSans-Bold.ttf"), font_size: 14.0, color: get_sender_color(sender), }); } else { header.spawn(Text2d { text: "System".into(), font: asset_server.load("fonts/FiraSans-Bold.ttf"), font_size: 14.0, color: SYSTEM_NAME_COLOR, }); } // Timestamp header.spawn(Text2d { text: time_ago, font: asset_server.load("fonts/FiraSans-Italic.ttf"), font_size: 12.0, color: Color::rgba(0.7, 0.7, 0.7, 1.0), }); }); // Message content message_container.spawn(Text2d { text: message.content.clone(), font: asset_server.load("fonts/FiraSans-Regular.ttf"), font_size: 14.0, color: get_message_text_color(&message.message_type), }); }); } }
Input Handling
#![allow(unused)] fn main() { /// System to handle chat input fn handle_chat_input( mut char_input_events: EventReader<ReceivedCharacter>, keyboard_input: Res<Input<KeyCode>>, mut chat_state: ResMut<ChatState>, mut chat_events: EventWriter<SendChatMessageEvent>, local_player: Res<LocalPlayer>, ) { if chat_state.is_input_focused { // Process character input for event in char_input_events.read() { if !event.char.is_control() { chat_state.current_input.push(event.char); } } // Handle backspace if keyboard_input.just_pressed(KeyCode::Backspace) && !chat_state.current_input.is_empty() { chat_state.current_input.pop(); } // Handle Enter to send if keyboard_input.just_pressed(KeyCode::Return) && !chat_state.current_input.is_empty() { // Create chat message event chat_events.send(SendChatMessageEvent { content: chat_state.current_input.clone(), sender: local_player.name.clone(), message_type: MessageType::Chat, }); // Clear input chat_state.current_input.clear(); } } } /// System to handle send button clicks fn handle_send_button( mut interaction_query: Query<&Interaction, (Changed<Interaction>, With<ChatSendButton>)>, mut chat_state: ResMut<ChatState>, mut chat_events: EventWriter<SendChatMessageEvent>, local_player: Res<LocalPlayer>, ) { for interaction in &mut interaction_query { if *interaction == Interaction::Pressed && !chat_state.current_input.is_empty() { // Create chat message event chat_events.send(SendChatMessageEvent { content: chat_state.current_input.clone(), sender: local_player.name.clone(), message_type: MessageType::Chat, }); // Clear input chat_state.current_input.clear(); } } } }
The chat system provides an essential communication channel for players to coordinate before starting their Commander game, helping ensure a fun and balanced gaming experience.
Lobby Deck System
This section covers the deck management functionality in the game lobby, allowing players to select, view, and validate decks before starting a game.
Overview
The lobby deck system provides interfaces for players to interact with their deck collection before a game begins, ensuring that selected decks meet format requirements.
Components
Viewer
Details of the deck viewer interface, including:
- Deck list display
- Card preview functionality
- Deck statistics and analysis
- Format legality checking
- Commander and color identity validation
- Deck sharing options
Features
- Deck selection from player's collection
- Format validation for Commander rules
- Deck statistics and composition analysis
- Last-minute deck adjustments
- Deck sharing with other players
- Deck import/export functionality
Integration
The deck system integrates with:
- Card Database for card information
- Deck Database for persistent storage
- Lobby UI for display integration
- Lobby Backend for deck validation and game setup
Lobby Deck Viewer UI
This document describes the deck viewer UI component in the multiplayer lobby. This feature allows players to view each other's commanders and deck information before starting a game, which helps ensure balanced gameplay and appropriate power levels.
Table of Contents
Overview
The deck viewer occupies the right panel of the lobby detail screen, providing a preview of selected decks and their commanders. This feature promotes transparency and helps players ensure they're entering a game with compatible deck power levels.
┌───────────────────────────────────┐
│ Deck Viewer │
├───────────────────────────────────┤
│ │
│ ┌─────────────────────────────┐ │
│ │ │ │
│ │ │ │
│ │ Commander Card │ │
│ │ │ │
│ │ │ │
│ └─────────────────────────────┘ │
│ │
│ Player: Player2 │
│ Deck: "Competitive Elves" │
│ │
│ Color Identity: G │
│ Average CMC: 2.8 │
│ Power Level: 7 │
│ │
│ ┌─────────────────────────────┐ │
│ │ View Full Decklist (15/60) │ │
│ └─────────────────────────────┘ │
└───────────────────────────────────┘
UI Components
The deck viewer consists of multiple components:
Commander Card Display
- Large card image for the commander
- Partner commander toggle (if applicable)
- Card details (name, mana cost, type line, rules text)
- Color identity indicators
Deck Information
- Deck name
- Owner's name
- Format legality
- Deck description (optional)
Deck Statistics
- Color identity
- Card count
- Average mana value
- Card type distribution
- Mana curve graph
- Estimated power level
Action Buttons
- View full decklist button (if shared by owner)
- Request deck details button
- Select deck button (for local player)
Privacy Controls
Players have control over how much of their deck information is shared:
#![allow(unused)] fn main() { /// Deck privacy settings #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum DeckPrivacyLevel { /// Only commander is visible CommanderOnly, /// Commander and basic stats are visible BasicStats, /// Full decklist is shared FullDecklist, /// Custom setting with specific visibility options Custom(DeckPrivacyOptions), } /// Detailed privacy options for decks #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct DeckPrivacyOptions { /// Whether to show the commander pub show_commander: bool, /// Whether to show basic statistics pub show_stats: bool, /// Whether to show card type breakdown pub show_card_types: bool, /// Whether to show mana curve pub show_mana_curve: bool, /// Maximum number of cards to reveal (0 = none) pub cards_to_reveal: usize, /// Categories of cards to reveal pub reveal_categories: Vec<CardCategory>, } }
Players set their privacy preferences when readying up with a deck:
#![allow(unused)] fn main() { /// System to handle deck privacy settings fn handle_deck_privacy_settings( mut interaction_query: Query< (&Interaction, &DeckPrivacyOption), (Changed<Interaction>, With<Button>), >, mut selected_deck: ResMut<SelectedDeck>, ) { for (interaction, privacy_option) in &mut interaction_query { if *interaction == Interaction::Pressed { selected_deck.privacy_level = privacy_option.level.clone(); } } } }
Commander Preview
The commander preview is the centerpiece of the deck viewer:
#![allow(unused)] fn main() { /// Set up the commander preview display fn setup_commander_preview( parent: &mut ChildBuilder, asset_server: &Res<AssetServer>, commander_info: Option<&CommanderInfo>, ) { // Commander card container parent .spawn(Node { width: Val::Percent(100.0), height: Val::Px(300.0), justify_content: JustifyContent::Center, align_items: AlignItems::Center, margin: UiRect::bottom(Val::Px(15.0)), ..default() }) .with_children(|commander_container| { if let Some(commander) = commander_info { // Commander card image if let Some(ref image_uri) = commander.image_uri { commander_container.spawn(( Image { texture: asset_server.load(image_uri), ..default() }, Node { width: Val::Px(220.0), height: Val::Px(300.0), ..default() }, CommanderCard, )); } else { // Fallback if no image is available commander_container .spawn(( Node { width: Val::Px(220.0), height: Val::Px(300.0), flex_direction: FlexDirection::Column, padding: UiRect::all(Val::Px(10.0)), ..default() }, BackgroundColor(Color::rgb(0.1, 0.1, 0.1)), BorderColor(Color::rgb(0.3, 0.3, 0.3)), Outline::new(Val::Px(2.0)), CommanderCard, )) .with_children(|card| { // Card name and mana cost card.spawn(Node { width: Val::Percent(100.0), height: Val::Auto, justify_content: JustifyContent::SpaceBetween, align_items: AlignItems::Center, margin: UiRect::bottom(Val::Px(10.0)), ..default() }).with_children(|header| { // Card name header.spawn(Text2d { text: commander.name.clone(), font: asset_server.load("fonts/FiraSans-Bold.ttf"), font_size: 18.0, color: Color::WHITE, }); // Mana cost header.spawn(Text2d { text: commander.mana_cost.clone(), font: asset_server.load("fonts/FiraSans-Bold.ttf"), font_size: 18.0, color: Color::WHITE, }); }); // Type line card.spawn(Text2d { text: commander.type_line.clone(), font: asset_server.load("fonts/FiraSans-Italic.ttf"), font_size: 14.0, color: Color::rgb(0.9, 0.9, 0.9), }); // Rules text card.spawn(Node { width: Val::Percent(100.0), height: Val::Percent(70.0), margin: UiRect::top(Val::Px(10.0)), ..default() }).with_children(|text_box| { text_box.spawn(Text2d { text: commander.text.clone(), font: asset_server.load("fonts/FiraSans-Regular.ttf"), font_size: 14.0, color: Color::WHITE, }); }); // Power/Toughness if applicable if let Some(ref pt) = commander.power_toughness { card.spawn(Node { width: Val::Percent(100.0), height: Val::Auto, justify_content: JustifyContent::FlexEnd, ..default() }).with_children(|pt_container| { pt_container.spawn(Text2d { text: pt.clone(), font: asset_server.load("fonts/FiraSans-Bold.ttf"), font_size: 16.0, color: Color::WHITE, }); }); } }); } } else { // No commander selected yet commander_container .spawn(( Node { width: Val::Px(220.0), height: Val::Px(300.0), justify_content: JustifyContent::Center, align_items: AlignItems::Center, ..default() }, BackgroundColor(Color::rgba(0.1, 0.1, 0.1, 0.5)), BorderColor(Color::rgba(0.3, 0.3, 0.3, 0.5)), Outline::new(Val::Px(1.0)), EmptyCommanderCard, )) .with_children(|empty_card| { empty_card.spawn(Text2d { text: "No Commander Selected".into(), font: asset_server.load("fonts/FiraSans-Italic.ttf"), font_size: 16.0, color: Color::rgba(0.7, 0.7, 0.7, 1.0), }); }); } }); } }
Deck Statistics
The deck statistics section provides additional information:
#![allow(unused)] fn main() { /// Set up the deck statistics display fn setup_deck_statistics( parent: &mut ChildBuilder, asset_server: &Res<AssetServer>, deck_info: Option<&DeckInfo>, ) { // Stats container parent .spawn(Node { width: Val::Percent(100.0), height: Val::Auto, flex_direction: FlexDirection::Column, padding: UiRect::all(Val::Px(10.0)), margin: UiRect::bottom(Val::Px(10.0)), ..default() }) .with_children(|stats_container| { if let Some(deck) = deck_info { // Player and deck name stats_container.spawn(Text2d { text: format!("Player: {}", deck.player_name), font: asset_server.load("fonts/FiraSans-Bold.ttf"), font_size: 16.0, color: Color::WHITE, }); stats_container.spawn(Text2d { text: format!("Deck: \"{}\"", deck.name), font: asset_server.load("fonts/FiraSans-Bold.ttf"), font_size: 16.0, color: Color::WHITE, }); // Color identity stats_container .spawn(Node { width: Val::Percent(100.0), height: Val::Auto, flex_direction: FlexDirection::Row, align_items: AlignItems::Center, margin: UiRect::vertical(Val::Px(5.0)), ..default() }) .with_children(|color_row| { color_row.spawn(Text2d { text: "Color Identity: ".into(), font: asset_server.load("fonts/FiraSans-Regular.ttf"), font_size: 14.0, color: Color::WHITE, }); // Display color pips for color in &deck.colors { color_row.spawn(( Node { width: Val::Px(20.0), height: Val::Px(20.0), margin: UiRect::left(Val::Px(2.0)), ..default() }, BackgroundColor(get_color_identity_color(color)), BorderColor(Color::WHITE), Outline::new(Val::Px(1.0)), ColorIdentityPip(color.clone()), )); } }); // Other statistics stats_container.spawn(Text2d { text: format!("Average CMC: {:.1}", deck.avg_mana_value), font: asset_server.load("fonts/FiraSans-Regular.ttf"), font_size: 14.0, color: Color::WHITE, }); stats_container.spawn(Text2d { text: format!("Power Level: {}/10", deck.power_level), font: asset_server.load("fonts/FiraSans-Regular.ttf"), font_size: 14.0, color: get_power_level_color(deck.power_level), }); // View full decklist button if deck.share_decklist { stats_container .spawn(( Button, Node { width: Val::Percent(100.0), height: Val::Px(40.0), justify_content: JustifyContent::Center, align_items: AlignItems::Center, margin: UiRect::top(Val::Px(10.0)), ..default() }, BackgroundColor(Color::rgba(0.2, 0.4, 0.8, 0.7)), ViewDecklistButton(deck.player_id.clone()), )) .with_children(|button| { button.spawn(Text2d { text: format!("View Full Decklist ({}/{})", deck.card_count, deck.card_count), font: asset_server.load("fonts/FiraSans-Regular.ttf"), font_size: 14.0, color: Color::WHITE, }); }); } else { // Decklist not shared stats_container .spawn(( Node { width: Val::Percent(100.0), height: Val::Px(40.0), justify_content: JustifyContent::Center, align_items: AlignItems::Center, margin: UiRect::top(Val::Px(10.0)), ..default() }, BackgroundColor(Color::rgba(0.2, 0.2, 0.2, 0.7)), )) .with_children(|container| { container.spawn(Text2d { text: "Decklist Not Shared".into(), font: asset_server.load("fonts/FiraSans-Italic.ttf"), font_size: 14.0, color: Color::rgba(0.7, 0.7, 0.7, 1.0), }); }); } } else { // No deck selected stats_container.spawn(Text2d { text: "No Deck Selected".into(), font: asset_server.load("fonts/FiraSans-Italic.ttf"), font_size: 16.0, color: Color::rgba(0.7, 0.7, 0.7, 1.0), }); } }); } }
Implementation
The deck viewer is implemented using Bevy's ECS architecture:
Overall Deck Viewer Setup
#![allow(unused)] fn main() { /// Set up the deck viewer panel UI pub fn setup_deck_viewer_panel( parent: &mut ChildBuilder, asset_server: &Res<AssetServer>, ) { // Deck viewer container parent .spawn(( Node { width: Val::Percent(30.0), height: Val::Percent(100.0), flex_direction: FlexDirection::Column, ..default() }, DeckViewerUI, )) .with_children(|deck_viewer| { // Header deck_viewer .spawn(Node { width: Val::Percent(100.0), height: Val::Px(30.0), justify_content: JustifyContent::Center, align_items: AlignItems::Center, margin: UiRect::bottom(Val::Px(10.0)), ..default() }) .with_children(|header| { header.spawn(Text2d { text: "DECK VIEWER".into(), font: asset_server.load("fonts/FiraSans-Bold.ttf"), font_size: 18.0, color: Color::WHITE, }); }); // Deck selection tabs setup_deck_selection_tabs(deck_viewer, asset_server); // Commander preview area setup_commander_preview(deck_viewer, asset_server, None); // Deck statistics area setup_deck_statistics(deck_viewer, asset_server, None); }); } /// Set up tabs to select which player's deck to view fn setup_deck_selection_tabs( parent: &mut ChildBuilder, asset_server: &Res<AssetServer>, ) { parent .spawn(( Node { width: Val::Percent(100.0), height: Val::Px(40.0), flex_direction: FlexDirection::Row, margin: UiRect::bottom(Val::Px(10.0)), ..default() }, DeckSelectionTabs, )); // Tabs will be populated dynamically based on players in lobby } }
Deck Selection
The viewer supports selecting whose deck to view:
#![allow(unused)] fn main() { /// System to update deck selection tabs fn update_deck_selection_tabs( mut commands: Commands, asset_server: Res<AssetServer>, lobby_info: Res<CurrentLobbyInfo>, tabs_query: Query<Entity, With<DeckSelectionTabs>>, children_query: Query<Entity>, selected_player_deck: Res<SelectedPlayerDeck>, ) { if lobby_info.is_changed() || selected_player_deck.is_changed() { // Get the tabs container if let Ok(tabs_entity) = tabs_query.get_single() { // Clear existing tabs let children = children_query.iter_descendants(tabs_entity); for child in children { commands.entity(child).despawn_recursive(); } // Add tabs for each player with a selected deck commands.entity(tabs_entity).with_children(|tabs| { for player in lobby_info.players.values() { if player.status == PlayerLobbyState::Ready { let is_selected = selected_player_deck.player_id == Some(player.id.clone()); tabs.spawn(( Button, Node { width: Val::Auto, min_width: Val::Px(100.0), height: Val::Percent(100.0), justify_content: JustifyContent::Center, align_items: AlignItems::Center, padding: UiRect::horizontal(Val::Px(10.0)), margin: UiRect::right(Val::Px(5.0)), ..default() }, BackgroundColor(if is_selected { Color::rgba(0.3, 0.5, 0.8, 0.7) } else { Color::rgba(0.2, 0.2, 0.2, 0.7) }), DeckSelectionTab(player.id.clone()), )).with_children(|tab| { tab.spawn(Text2d { text: player.name.clone(), font: asset_server.load("fonts/FiraSans-Regular.ttf"), font_size: 14.0, color: Color::WHITE, }); }); } } }); } } } /// System to handle deck tab selection fn handle_deck_tab_selection( mut interaction_query: Query< (&Interaction, &DeckSelectionTab), (Changed<Interaction>, With<Button>), >, mut selected_player_deck: ResMut<SelectedPlayerDeck>, ) { for (interaction, tab) in &mut interaction_query { if *interaction == Interaction::Pressed { selected_player_deck.player_id = Some(tab.0.clone()); } } } }
Full Decklist Viewer
When a player clicks to view a full decklist (if shared):
#![allow(unused)] fn main() { /// System to handle decklist view button fn handle_view_decklist_button( mut interaction_query: Query< (&Interaction, &ViewDecklistButton), (Changed<Interaction>, With<Button>), >, mut deck_view_events: EventWriter<ViewDecklistEvent>, ) { for (interaction, button) in &mut interaction_query { if *interaction == Interaction::Pressed { deck_view_events.send(ViewDecklistEvent(button.0.clone())); } } } /// System to show full decklist popup fn show_decklist_popup( mut commands: Commands, mut deck_view_events: EventReader<ViewDecklistEvent>, asset_server: Res<AssetServer>, lobby_connection: Res<LobbyConnection>, ) { for event in deck_view_events.read() { // Request decklist from server lobby_connection.request_decklist(event.0.clone()); // Show loading popup commands .spawn(( Node { width: Val::Percent(80.0), height: Val::Percent(80.0), position_type: PositionType::Absolute, position: UiRect { left: Val::Percent(10.0), top: Val::Percent(10.0), ..default() }, flex_direction: FlexDirection::Column, padding: UiRect::all(Val::Px(20.0)), ..default() }, BackgroundColor(Color::rgba(0.1, 0.1, 0.1, 0.95)), BorderColor(Color::rgba(0.3, 0.3, 0.3, 1.0)), Outline::new(Val::Px(2.0)), DecklistPopup, )) .with_children(|popup| { // Header popup .spawn(Node { width: Val::Percent(100.0), height: Val::Px(40.0), justify_content: JustifyContent::SpaceBetween, align_items: AlignItems::Center, margin: UiRect::bottom(Val::Px(20.0)), ..default() }) .with_children(|header| { // Title header.spawn(Text2d { text: "Loading Decklist...".into(), font: asset_server.load("fonts/FiraSans-Bold.ttf"), font_size: 24.0, color: Color::WHITE, }); // Close button header .spawn(( Button, Node { width: Val::Px(30.0), height: Val::Px(30.0), justify_content: JustifyContent::Center, align_items: AlignItems::Center, ..default() }, BackgroundColor(Color::rgba(0.7, 0.3, 0.3, 0.7)), CloseDecklistPopupButton, )) .with_children(|button| { button.spawn(Text2d { text: "X".into(), font: asset_server.load("fonts/FiraSans-Bold.ttf"), font_size: 18.0, color: Color::WHITE, }); }); }); // Loading indicator popup.spawn(( Node { width: Val::Percent(100.0), height: Val::Percent(100.0), justify_content: JustifyContent::Center, align_items: AlignItems::Center, ..default() }, DecklistContentContainer, )).with_children(|container| { container.spawn(Text2d { text: "Loading...".into(), font: asset_server.load("fonts/FiraSans-Italic.ttf"), font_size: 18.0, color: Color::rgba(0.7, 0.7, 0.7, 1.0), }); }); }); } } }
The deck viewer provides a crucial function in multiplayer Commander games by allowing players to gauge deck compatibility before starting a game, helping to ensure a balanced and enjoyable experience for all participants.
Lobby UI System
This section covers the user interface components of the game lobby, providing players with visual access to matchmaking, game creation, and pre-game setup.
Overview
The lobby UI system serves as the player's primary interface for finding and joining games, managing their profile, and preparing for gameplay.
Components
Overview
General overview of the lobby UI system, including:
- Main lobby screen layout
- Navigation structure
- Visual design principles
- Accessibility considerations
- Responsive design for different screen sizes
Detail View
Detailed information about specific UI components, including:
- Game room cards and information display
- Player profile and statistics panels
- Deck selection interface
- Game creation wizard
- Settings and preferences panels
- Friend list and social features
Features
- Intuitive game browsing and filtering
- Quick join and matchmaking options
- Game room creation with customizable settings
- Player profile and statistics display
- Friend list and social interaction
- Deck selection and validation
- Pre-game setup and configuration
Integration
The lobby UI integrates with:
- Lobby Backend for data and state management
- Chat System for player communication
- Deck System for deck selection and management
- Game UI for consistent visual language
Multiplayer Lobby Detail UI
This document outlines the user interface for the lobby detail screen in the multiplayer system. This screen is displayed after a player joins a specific lobby and is where players prepare for the game, interact with each other, and manage their participation.
Table of Contents
- UI Layout
- Components
- Player Management
- Player Actions
- Host Controls
- Handling Player Departures
- Implementation
UI Layout
The lobby detail screen is divided into three main panels:
- Left Panel: Lobby information and player list
- Center Panel: Chat system
- Right Panel: Deck viewer
┌───────────────────────────────────────────────────────┐
│ Lobby Name Host: PlayerName │
├───────────────┬───────────────────┬───────────────────┤
│ │ │ │
│ PLAYERS │ │ DECK VIEWER │
│ │ │ │
│ ○ Player1 │ │ ┌─────────────┐ │
│ (Host) │ │ │ │ │
│ │ │ │ Commander │ │
│ ○ Player2 │ CHAT │ │ Card │ │
│ [Ready] │ │ │ │ │
│ │ │ └─────────────┘ │
│ ○ Player3 │ │ │
│ [Selecting] │ │ Deck Statistics │
│ │ │ │
│ ○ Player4 │ │ │
│ │ │ │
├───────────────┴───────────────────┴───────────────────┤
│ [Leave Lobby] [Select Deck] [Ready Up] │
└───────────────────────────────────────────────────────┘
Components
Lobby Header
The header displays essential information about the lobby:
- Lobby name
- Host name
- Game format (Standard Commander, cEDH, etc.)
- Player count (current/maximum)
- Password protection indicator
Player List
The player list shows all players in the lobby with their current status:
- Player name
- Ready status
- Selected deck (if ready)
- Host indicator
- Color identity of selected commander (if ready)
Ready Controls
Players can indicate their readiness through:
- Deck selection button
- Ready/Unready toggle
- Current status indicator
Player Management
The lobby implements a robust player management system to handle various player actions and states.
Player States
Players can be in various states while in the lobby:
#![allow(unused)] fn main() { /// Player state in the lobby #[derive(Component, Clone, Debug, PartialEq, Eq)] pub enum PlayerLobbyState { /// Just joined, not ready Joined, /// Selecting a deck SelectingDeck, /// Ready with deck selected Ready, /// Temporarily disconnected (can reconnect) Disconnected, /// In the process of joining Joining, } }
Player Actions
Players can perform several actions in the lobby:
Selecting a Deck
Players need to select a deck before they can ready up:
#![allow(unused)] fn main() { fn handle_deck_selection( mut interaction_query: Query<&Interaction, (Changed<Interaction>, With<DeckSelectButton>)>, mut commands: Commands, mut next_state: ResMut<NextState<DeckSelectionState>>, ) { for interaction in &mut interaction_query { if *interaction == Interaction::Pressed { // Transition to deck selection screen next_state.set(DeckSelectionState::Selecting); } } } }
Ready Status
Players indicate they are ready to play:
#![allow(unused)] fn main() { fn handle_ready_button( mut interaction_query: Query<&Interaction, (Changed<Interaction>, With<ReadyButton>)>, mut player_query: Query<&mut PlayerLobbyState, With<LocalPlayer>>, mut lobby_connection: ResMut<LobbyConnection>, ) { for interaction in &mut interaction_query { if *interaction == Interaction::Pressed { // Toggle ready status if let Ok(mut state) = player_query.get_single_mut() { match *state { PlayerLobbyState::Ready => { *state = PlayerLobbyState::Joined; lobby_connection.send_status_update(PlayerLobbyState::Joined); } _ => { if lobby_connection.has_deck_selected() { *state = PlayerLobbyState::Ready; lobby_connection.send_status_update(PlayerLobbyState::Ready); } } } } } } } }
Leaving a Lobby
Players can voluntarily leave a lobby:
#![allow(unused)] fn main() { fn handle_leave_button( mut interaction_query: Query<&Interaction, (Changed<Interaction>, With<LeaveButton>)>, mut lobby_connection: ResMut<LobbyConnection>, mut next_state: ResMut<NextState<GameMenuState>>, ) { for interaction in &mut interaction_query { if *interaction == Interaction::Pressed { // Send leave notification to server lobby_connection.leave_lobby(); // Return to lobby browser next_state.set(GameMenuState::MultiplayerBrowser); } } } }
Host Controls
The host player has additional controls for managing the lobby:
Start Game
The host can start the game when all players are ready:
#![allow(unused)] fn main() { fn handle_start_game_button( mut interaction_query: Query<&Interaction, (Changed<Interaction>, With<StartGameButton>)>, mut lobby_connection: ResMut<LobbyConnection>, player_query: Query<&PlayerLobbyState>, ) { for interaction in &mut interaction_query { if *interaction == Interaction::Pressed { // Check if all players are ready let all_ready = player_query .iter() .all(|state| *state == PlayerLobbyState::Ready); if all_ready { lobby_connection.start_game(); } } } } }
Kick Player
The host can remove players from the lobby:
#![allow(unused)] fn main() { fn handle_kick_button( mut interaction_query: Query<(&Interaction, &KickButtonTarget), (Changed<Interaction>, With<KickButton>)>, mut lobby_connection: ResMut<LobbyConnection>, ) { for (interaction, target) in &mut interaction_query { if *interaction == Interaction::Pressed { // Send kick request to server lobby_connection.kick_player(target.player_id.clone()); } } } }
Handling Player Departures
The system handles various scenarios for player departures from the lobby:
Voluntary Departure
When a player chooses to leave a lobby:
- The player initiates departure through the Leave button
- A leave message is sent to the server
- The server broadcasts the player's departure to all other players
- The player's UI transitions to the lobby browser
- Other players receive a notification of the departure
#![allow(unused)] fn main() { /// Message sent when a player leaves a lobby #[derive(Serialize, Deserialize, Clone, Debug)] pub struct LeaveLobbyMessage { /// ID of the lobby being left pub lobby_id: String, /// Reason for leaving pub reason: LeaveLobbyReason, } /// Reasons a player might leave a lobby #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub enum LeaveLobbyReason { /// Player chose to leave Voluntary, /// Player was kicked by the host Kicked, /// Player disconnected unexpectedly Disconnected, /// Player was idle for too long Timeout, } }
Being Kicked
When a host kicks a player:
- The host selects a player and clicks the Kick button
- A kick message is sent to the server
- The server validates the request (ensures sender is host)
- The server sends a departure notification to the kicked player
- The kicked player's UI transitions to the lobby browser with a message
- Other players receive a notification that the player was kicked
#![allow(unused)] fn main() { /// System to handle being kicked from a lobby fn handle_being_kicked( mut kick_events: EventReader<KickedFromLobbyEvent>, mut commands: Commands, mut next_state: ResMut<NextState<GameMenuState>>, ) { for event in kick_events.read() { // Display kicked message commands.spawn(KickedNotificationUI { reason: event.reason.clone(), }); // Return to lobby browser next_state.set(GameMenuState::MultiplayerBrowser); } } }
Disconnections
The system also handles unexpected disconnections:
- When a player disconnects, the server detects the dropped connection
- The server broadcasts a player disconnection to all remaining players
- The server keeps the player's slot reserved for a period of time
- If the player reconnects within the time window, they rejoin seamlessly
- If the reconnection window expires, the slot is freed and other players are notified
#![allow(unused)] fn main() { /// System to handle player disconnections fn handle_player_disconnection( mut disconnection_events: EventReader<PlayerDisconnectedEvent>, mut player_query: Query<(&mut PlayerLobbyState, &PlayerName)>, mut lobby_state: ResMut<LobbyState>, ) { for event in disconnection_events.read() { // Update the disconnected player's state for (mut state, name) in &mut player_query { if name.0 == event.player_name { *state = PlayerLobbyState::Disconnected; // Start reconnection timer lobby_state.disconnection_timers.insert( event.player_name.clone(), ReconnectionTimer { time_remaining: RECONNECTION_WINDOW, player_id: event.player_id.clone(), } ); break; } } } } }
Host Migration
If the host leaves or disconnects, the system can migrate host privileges to another player:
- When the host leaves, the server selects the next player (typically by join time)
- The server broadcasts a host migration message to all players
- The new host receives additional UI controls
- All players are notified of the host change
#![allow(unused)] fn main() { /// Message for host migration #[derive(Serialize, Deserialize, Clone, Debug)] pub struct HostMigrationMessage { /// ID of the new host pub new_host_id: String, /// Name of the new host pub new_host_name: String, } /// System to handle host migration fn handle_host_migration( mut migration_events: EventReader<HostMigrationEvent>, mut player_query: Query<(&mut PlayerLobbyState, &PlayerName, &mut PlayerUI)>, mut lobby_state: ResMut<LobbyState>, ) { for event in migration_events.read() { // Update host status lobby_state.host_id = event.new_host_id.clone(); // Update UI to reflect new host for (state, name, mut ui) in &mut player_query { if name.0 == event.new_host_name { ui.show_host_controls(); } } } } }
Implementation
The lobby detail UI is implemented using Bevy's UI system:
#![allow(unused)] fn main() { /// Set up the lobby detail screen pub fn setup_lobby_detail( mut commands: Commands, asset_server: Res<AssetServer>, lobby_info: Res<CurrentLobbyInfo>, ) { // Main container commands .spawn(( Node { width: Val::Percent(100.0), height: Val::Percent(100.0), flex_direction: FlexDirection::Column, ..default() }, LobbyDetailUI, )) .with_children(|parent| { // Header setup_lobby_header(parent, &asset_server, &lobby_info); // Main content area parent .spawn(Node { width: Val::Percent(100.0), height: Val::Percent(85.0), flex_direction: FlexDirection::Row, ..default() }) .with_children(|content| { // Left panel (player list) setup_player_list_panel(content, &asset_server, &lobby_info); // Center panel (chat) setup_chat_panel(content, &asset_server); // Right panel (deck viewer) setup_deck_viewer_panel(content, &asset_server); }); // Footer with action buttons setup_action_buttons(parent, &asset_server, &lobby_info); }); } }
This UI adapts based on the player's role (host or regular player) and provides appropriate controls for each state.
Multiplayer Lobby UI System Overview
This document provides a high-level overview of the multiplayer lobby user interface system for Rummage's Commander format game. For more detailed information on specific components, please see the related documentation files.
Table of Contents
- UI Architecture Overview
- UI Flow
- Screen Components
- Integration with Game States
- Related Documentation
UI Architecture Overview
The multiplayer lobby system uses Bevy's UI components, with a focus on clean, responsive design that works well across different screen sizes. The UI is built using these key Bevy components:
Node
for layout containersButton
for interactive elementsText2d
for text displayImage
for graphics and icons
The UI follows a component-based architecture where different parts of the interface are organized hierarchically and can be created, updated, or removed independently.
UI Flow
The user flow through the multiplayer lobby system follows this sequence:
- Main Menu: Player clicks the "Multiplayer" button in the main menu
- Server Connection: Player selects a server or enters a direct IP address
- Lobby Browser: Player views a list of available game lobbies
- Lobby Detail: Player joins a lobby and prepares for the game
- Game Launch: When all players are ready, the host launches the game
Each of these screens has its own set of UI components and systems to handle user interaction.
┌─────────────┐
│ Main Menu │
└──────┬──────┘
│
▼
┌─────────────────┐
│ Server Connection│
└────────┬────────┘
│
▼
┌─────────────────┐
│ Lobby Browser │◄────┐
└────────┬────────┘ │
│ │
▼ │
┌─────────────────┐ │
│ Lobby Detail │─────┘
└────────┬────────┘
│
▼
┌─────────────────┐
│ Game Launch │
└─────────────────┘
Screen Components
Each screen in the lobby system consists of multiple UI components:
1. Server Connection Screen
- Server list with ping indicators
- Direct IP connection field
- Connect button
- Back button
2. Lobby Browser Screen
- Lobby list with game information
- Filter and sort controls
- Create lobby button
- Refresh button
- Lobby details panel
- Join lobby button
3. Lobby Detail Screen
- Lobby information header
- Player list with status indicators
- Chat panel
- Deck selection controls
- Ready up button
- Deck viewing section
4. Game Launch Transition
- Loading screen
- Connection status indicators
- Game initialization progress
Integration with Game States
The lobby UI system integrates with Bevy's state management system:
#![allow(unused)] fn main() { /// Game states extended to include multiplayer states #[derive(States, Debug, Clone, Copy, Eq, PartialEq, Hash, Default)] pub enum GameMenuState { /// Initial state, showing the main menu #[default] MainMenu, /// Multiplayer lobby browser MultiplayerBrowser, /// Inside a specific lobby MultiplayerLobby, /// Transitional state for loading game assets Loading, /// Active gameplay state InGame, /// Game is paused, showing pause menu PausedGame, } }
State transitions are handled by systems that respond to user interactions and network events:
#![allow(unused)] fn main() { fn handle_multiplayer_button( mut interaction_query: Query< (&Interaction, &MenuButtonAction, &mut BackgroundColor), (Changed<Interaction>, With<Button>), >, mut next_state: ResMut<NextState<GameMenuState>>, ) { for (interaction, action, mut color) in &mut interaction_query { if *interaction == Interaction::Pressed && *action == MenuButtonAction::Multiplayer { next_state.set(GameMenuState::MultiplayerBrowser); } } } }
Related Documentation
For more detailed information about specific aspects of the lobby UI system, please refer to these documents:
- Lobby Browser UI - Details of the lobby browsing interface
- Lobby Detail UI - Details of the specific lobby view
- Lobby Chat UI - Chat system implementation
- Lobby Deck Viewer - Deck and commander viewing UI
- Lobby Networking - Network architecture for the lobby system
- Lobby Backend - Server-side implementation details
Gameplay Networking Documentation
This section covers the networking aspects specific to gameplay in the MTG Commander game engine. While the lobby system handles pre-game setup, the gameplay networking components manage the actual game session, including state synchronization, player actions, and handling departures.
Overview
The gameplay networking implementation focuses on several key areas:
- State Management: Maintaining and synchronizing the game state across all clients
- Action Processing: Handling player actions and their effects on the game state
- Synchronization: Ensuring all clients have a consistent view of the game
- Departure Handling: Managing player disconnections and reconnections
Gameplay Components
State Management
The State Management system handles the representation and replication of game state:
- Core game state structure and components
- State replication with bevy_replicon
- Hidden information management
- State consistency and verification
- Rollback and Recovery for handling network disruptions
Synchronization
The Synchronization system ensures all clients maintain a consistent view of the game:
- Server-authoritative model
- Command-based synchronization
- Incremental state updates
- Tick-based processing
- Handling network issues
Departure Handling
The Departure Handling system manages player disconnections and reconnections:
- Detecting disconnections
- Preserving game state for disconnected players
- Handling reconnections
- Game continuation policies
- Timeout and abandonment handling
Implementation Principles
Our gameplay networking implementation follows these core principles:
- Server Authority: The server is the single source of truth for game state
- Minimal Network Usage: Only necessary information is transmitted
- Resilience: The system can handle network disruptions gracefully through deterministic rollbacks
- Security: Hidden information remains protected
- Fairness: All players have equal opportunity regardless of network conditions
- Determinism: Game actions produce identical results when replayed with the same RNG state
Integration with Other Systems
The gameplay networking components integrate with several other systems:
- Lobby System: For transitioning from lobby to game
- Security: For protecting hidden information and preventing cheating
- Testing: For validating network behavior and performance
Future Enhancements
Planned gameplay networking enhancements include:
- Spectator Mode: Allow non-players to watch games in progress
- Replay System: Record and replay games for analysis
- Enhanced Reconnection: More sophisticated state recovery for long disconnections
- Optimized Synchronization: Improved performance for complex game states
- Cross-Platform Play: Ensure consistent experience across different platforms
This documentation will evolve as the gameplay networking implementation progresses.
Game State Management in MTG Commander
This document outlines the approach to managing game state in the MTG Commander game engine's multiplayer implementation.
Table of Contents
- Overview
- Game State Components
- Implementation Approach
- State Snapshots
- State Synchronization
- Deterministic State Updates
- Hidden Information
- Rollbacks and Recovery
Overview
Proper game state management is critical for a multiplayer card game like Magic: The Gathering. The game state includes all information about the current game, including cards in various zones, player life totals, turn structure, and active effects. In a networked environment, this state must be synchronized across all clients while maintaining security and performance.
Game State Components
The game state in MTG Commander consists of several key components:
- Zones: Battlefield, hands, libraries, graveyards, exile, stack, and command zone
- Player Information: Life totals, mana pools, commander damage, etc.
- Turn Structure: Current phase, active player, priority player
- Effects: Ongoing effects, delayed triggers, replacement effects
- Game Metadata: Game ID, start time, game mode, etc.
Implementation Approach
Core Game State Structure
The game state is implemented as a collection of ECS components and resources:
#![allow(unused)] fn main() { // Core game state resource #[derive(Resource)] pub struct GameState { pub game_id: Uuid, pub start_time: DateTime<Utc>, pub game_mode: GameMode, pub turn_number: u32, pub current_phase: Phase, pub active_player_id: PlayerId, pub priority_player_id: Option<PlayerId>, pub stack: Vec<StackItem>, } // Player component #[derive(Component)] pub struct Player { pub id: PlayerId, pub client_id: ClientId, pub life_total: i32, pub mana_pool: ManaPool, pub commander_damage: HashMap<PlayerId, i32>, } // Zone components #[derive(Component)] pub struct Hand { pub cards: Vec<CardId>, } #[derive(Component)] pub struct Library { pub cards: Vec<CardId>, pub top_revealed: bool, } #[derive(Component)] pub struct Graveyard { pub cards: Vec<CardId>, } #[derive(Component)] pub struct CommandZone { pub cards: Vec<CardId>, } // Battlefield is a shared resource #[derive(Resource)] pub struct Battlefield { pub permanents: Vec<Entity>, } // Card component #[derive(Component)] pub struct Card { pub id: CardId, pub name: String, pub card_type: CardType, pub owner_id: PlayerId, pub controller_id: PlayerId, // Other card properties... } }
State Replication with bevy_replicon
The game state is replicated using bevy_replicon, with careful control over what information is sent to each client:
#![allow(unused)] fn main() { // Register components for replication fn register_replication(app: &mut App) { app.register_component_replication::<Player>() .register_component_replication::<Card>() // Only replicate public zone information .register_component_replication::<Graveyard>() .register_component_replication::<CommandZone>() // Register resources .register_resource_replication::<GameState>() .register_resource_replication::<Battlefield>(); // Hand and Library require special handling for hidden information app.register_component_replication_with::<Hand>( RuleFns { serialize: |hand, ctx| { // Only send full hand to the owner if ctx.client_id == ctx.client_entity_map.get_client_id(hand.owner_entity) { bincode::serialize(hand).ok() } else { // Send only card count to other players bincode::serialize(&HandInfo { card_count: hand.cards.len() }).ok() } }, deserialize: |bytes, ctx| { // Handle deserialization based on what was sent // ... }, } ); } }
State Synchronization
The game state is synchronized across clients using a combination of techniques:
- Initial State: Full game state is sent when a client connects
- Incremental Updates: Only changes are sent during gameplay
- Command-Based: Player actions are sent as commands, not direct state changes
- Authoritative Server: Server validates all commands before applying them
#![allow(unused)] fn main() { // System to process player commands fn process_player_commands( mut commands: Commands, mut command_events: EventReader<PlayerCommand>, game_state: Res<GameState>, players: Query<(Entity, &Player)>, // Other queries... ) { for command in command_events.read() { // Validate the command if !validate_command(command, &game_state, &players) { continue; } // Apply the command to the game state match command { PlayerCommand::PlayCard { player_id, card_id, targets } => { // Handle playing a card // ... }, PlayerCommand::ActivateAbility { permanent_id, ability_index, targets } => { // Handle activating an ability // ... }, // Other command types... } } } }
State Snapshots
In networked games, maintaining state consistency despite network disruptions is essential. Our MTG Commander implementation employs a comprehensive state rollback system for resilience:
- Complete documentation: State Rollback and Recovery
- Deterministic replay of game actions after network disruptions
- State snapshots at critical game moments
- RNG state preservation for consistent randomized outcomes
- Client-side prediction for responsive gameplay
The rollback system integrates tightly with our deterministic RNG implementation to ensure that random events like shuffling and coin flips remain consistent across network boundaries, even during recovery from disruptions.
Deterministic State Updates
Maintaining state consistency is critical for a fair game experience. Several mechanisms ensure consistency:
- Sequence Numbers: Commands are processed in order
- State Verification: Periodic full state verification
- Reconciliation: Automatic correction of client-server state differences
- Rollback: Ability to roll back to a previous state if needed
#![allow(unused)] fn main() { // System to verify client state consistency fn verify_client_state_consistency( mut server: ResMut<RepliconServer>, game_state: Res<GameState>, connected_clients: Res<ConnectedClients>, ) { // Periodically send state verification requests if game_state.turn_number % 5 == 0 && game_state.current_phase == Phase::Upkeep { for client_id in connected_clients.clients.keys() { // Generate state verification data let verification_data = generate_state_verification_data(&game_state); // Send verification request server.send_message(*client_id, StateVerificationRequest { turn: game_state.turn_number, verification_data, }); } } } }
Hidden Information
In networked games, it's important to protect sensitive information from unauthorized access. MTG Commander implements several mechanisms to hide sensitive information:
- Encryption: All network communications are encrypted
- Access Control: Only authorized clients can access certain game state information
- Data Masking: Sensitive data is masked or obfuscated
Rollbacks and Recovery
In networked games, maintaining state consistency despite network disruptions is essential. Our MTG Commander implementation employs a comprehensive state rollback system for resilience:
- Complete documentation: State Rollback and Recovery
- Deterministic replay of game actions after network disruptions
- State snapshots at critical game moments
- RNG state preservation for consistent randomized outcomes
- Client-side prediction for responsive gameplay
The rollback system integrates tightly with our deterministic RNG implementation to ensure that random events like shuffling and coin flips remain consistent across network boundaries, even during recovery from disruptions.
Testing Game State Management
Testing the game state management system involves:
- Unit Tests: Testing individual state components and transitions
- Integration Tests: Testing state synchronization across multiple clients
- Stress Tests: Testing state management under high load or poor network conditions
For detailed testing procedures, see the Integration Testing Strategy.
Future Enhancements
Planned improvements to game state management include:
- Enhanced state compression for better network performance
- More sophisticated state reconciliation algorithms
- Support for game state snapshots and replays
- Improved handling of complex card interactions
This documentation will be updated as game state management evolves.
State Rollback and Recovery
This document outlines the implementation of state rollback and recovery mechanisms in our MTG Commander game engine, addressing network disruptions and maintaining gameplay integrity despite unstable connections.
Table of Contents
- Overview
- Rollback Architecture
- State Snapshots
- Deterministic Replay
- RNG Synchronization for Rollbacks
- Client-Side Prediction
- Recovery Processes
- Implementation Example
Overview
In networked gameplay, unstable connections can lead to state inconsistencies between the server and clients. The state rollback system allows the game to:
- Detect state deviations
- Revert to a previous valid state
- Deterministically replay actions to catch up
- Resume normal play without disrupting the game flow
This approach is particularly important for turn-based games like MTG Commander where the integrity of game state is critical.
Rollback Architecture
Our rollback architecture follows these principles:
- Server Authority: The server maintains the authoritative game state
- State History: Both server and clients maintain a history of game states
- Deterministic Replay: Actions can be replayed deterministically to reconstruct state
- Input Buffering: Client inputs are buffered to handle resynchronization
- Minimal Disruption: Rollbacks should be as seamless as possible to players
Component Integration
#![allow(unused)] fn main() { // src/networking/state/rollback.rs use bevy::prelude::*; use bevy_prng::WyRand; use bevy_rand::prelude::*; use crate::networking::server::resources::GameServer; use crate::game_engine::state::GameState; /// Plugin for handling state rollbacks in networked games pub struct StateRollbackPlugin; impl Plugin for StateRollbackPlugin { fn build(&self, app: &mut App) { app.init_resource::<StateHistory>() .init_resource::<ClientInputBuffer>() .add_systems(Update, ( create_state_snapshots, detect_state_deviations, handle_rollback_requests, apply_rollbacks, )); } } }
State Snapshots
The core of our rollback system is the ability to capture and restore game state snapshots:
#![allow(unused)] fn main() { /// Resource for tracking game state history #[derive(Resource)] pub struct StateHistory { /// Timestamped state snapshots pub snapshots: Vec<StateSnapshot>, /// Maximum number of snapshots to retain pub max_snapshots: usize, /// Time between state snapshots (in seconds) pub snapshot_interval: f32, /// Last snapshot time pub last_snapshot_time: f32, } impl Default for StateHistory { fn default() -> Self { Self { snapshots: Vec::new(), max_snapshots: 20, // Store up to 20 snapshots (~1 minute of gameplay at 3s intervals) snapshot_interval: 3.0, // Take a snapshot every 3 seconds last_snapshot_time: 0.0, } } } /// A complete snapshot of game state at a point in time #[derive(Clone, Debug)] pub struct StateSnapshot { /// Timestamp when this snapshot was created pub timestamp: f32, /// Unique sequence number pub sequence_id: u64, /// Serialized game state pub game_state: Vec<u8>, /// Serialized RNG state pub rng_state: Vec<u8>, /// Action sequence that led to this state pub action_sequence: Vec<ActionRecord>, } }
Creating Snapshots
#![allow(unused)] fn main() { /// System to periodically create game state snapshots pub fn create_state_snapshots( mut state_history: ResMut<StateHistory>, game_state: Res<GameState>, global_rng: Res<GlobalEntropy<WyRand>>, time: Res<Time>, sequence_tracker: Res<ActionSequence>, ) { // Check if it's time for a new snapshot if time.elapsed_seconds() - state_history.last_snapshot_time >= state_history.snapshot_interval { // Create new snapshot let snapshot = StateSnapshot { timestamp: time.elapsed_seconds(), sequence_id: sequence_tracker.current_sequence_id, game_state: serialize_game_state(&game_state), rng_state: global_rng.try_serialize_state().unwrap_or_default(), action_sequence: sequence_tracker.recent_actions.clone(), }; // Add to history state_history.snapshots.push(snapshot); state_history.last_snapshot_time = time.elapsed_seconds(); // Trim history if needed if state_history.snapshots.len() > state_history.max_snapshots { state_history.snapshots.remove(0); } } } }
Deterministic Replay
To ensure consistent rollback behavior, all game actions must be deterministic and replayable:
#![allow(unused)] fn main() { /// Record of a game action for replay purposes #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ActionRecord { /// Unique sequence ID for this action pub sequence_id: u64, /// Player who initiated the action pub player_id: Entity, /// Timestamp when the action occurred pub timestamp: f32, /// The actual action pub action: GameAction, } /// System to replay actions after a rollback pub fn replay_actions( mut commands: Commands, mut game_state: ResMut<GameState>, mut global_rng: ResMut<GlobalEntropy<WyRand>>, rollback_event: Res<RollbackEvent>, actions: Vec<ActionRecord>, ) { // Restore the game state and RNG to the rollback point deserialize_game_state(&mut game_state, &rollback_event.snapshot.game_state); global_rng.deserialize_state(&rollback_event.snapshot.rng_state).expect("Failed to restore RNG state"); // Replay all actions that occurred after the rollback point for action in actions { // Process each action in sequence apply_action(&mut commands, &mut game_state, &mut global_rng, action); } } }
RNG Synchronization for Rollbacks
The RNG state is critical for deterministic rollbacks. We extend our existing RNG synchronization to support rollbacks:
#![allow(unused)] fn main() { /// Resource to track RNG snapshots for rollback #[derive(Resource)] pub struct RngSnapshotHistory { /// History of RNG states indexed by sequence ID pub snapshots: HashMap<u64, Vec<u8>>, /// Maximum number of RNG snapshots to keep pub max_snapshots: usize, } impl Default for RngSnapshotHistory { fn default() -> Self { Self { snapshots: HashMap::new(), max_snapshots: 100, } } } /// System to capture RNG state before randomized actions pub fn capture_rng_before_randomized_action( sequence_tracker: Res<ActionSequence>, global_rng: Res<GlobalEntropy<WyRand>>, mut rng_history: ResMut<RngSnapshotHistory>, ) { // Save the current RNG state before a randomized action if let Some(serialized_state) = global_rng.try_serialize_state() { rng_history.snapshots.insert(sequence_tracker.current_sequence_id, serialized_state); // Clean up old snapshots if needed if rng_history.snapshots.len() > rng_history.max_snapshots { // Find and remove oldest snapshot if let Some(oldest_key) = rng_history.snapshots.keys() .min() .copied() { rng_history.snapshots.remove(&oldest_key); } } } } }
Client-Side Prediction
To minimize the perception of network issues, clients can implement prediction:
#![allow(unused)] fn main() { /// Resource to track client-side prediction state #[derive(Resource)] pub struct PredictionState { /// Actions predicted but not yet confirmed pub pending_actions: Vec<ActionRecord>, /// Whether prediction is currently active pub is_predicting: bool, /// Last confirmed server sequence ID pub last_confirmed_sequence: u64, } /// System to apply client-side prediction pub fn apply_client_prediction( mut commands: Commands, mut game_state: ResMut<GameState>, mut prediction: ResMut<PredictionState>, input: Res<Input<KeyCode>>, client: Res<GameClient>, ) { // Only predict for local player actions if let Some(local_player) = client.local_player { // Check if a new action was input if input.just_pressed(KeyCode::Space) { // Example: Predict a "pass turn" action let action = GameAction::PassTurn { player: local_player }; // Apply prediction locally apply_action_local(&mut commands, &mut game_state, action.clone()); // Record the prediction prediction.pending_actions.push(ActionRecord { sequence_id: prediction.last_confirmed_sequence + prediction.pending_actions.len() as u64 + 1, player_id: local_player, timestamp: 0.0, // Will be filled by server action, }); // Send to server // ... } } } }
Recovery Processes
When a network issue is detected, the recovery process begins:
#![allow(unused)] fn main() { /// Event triggered when a rollback is needed #[derive(Event)] pub struct RollbackEvent { /// The snapshot to roll back to pub snapshot: StateSnapshot, /// Reason for the rollback pub reason: RollbackReason, /// Clients affected by this rollback pub affected_clients: Vec<ClientId>, } /// Reasons for triggering a rollback #[derive(Debug, Clone, Copy)] pub enum RollbackReason { /// State divergence detected StateDivergence, /// Client reconnected after disconnect ClientReconnection, /// Server-forced rollback ServerForced, /// Desync in randomized outcome RandomizationDesync, } /// System to handle client reconnection with state recovery pub fn handle_client_reconnection( mut commands: Commands, mut server: ResMut<GameServer>, mut server_events: EventReader<ServerEvent>, state_history: Res<StateHistory>, mut rollback_events: EventWriter<RollbackEvent>, client_states: Res<ClientStateTracker>, ) { for event in server_events.read() { if let ServerEvent::ClientConnected { client_id } = event { // Check if this is a reconnection if let Some(player_entity) = server.client_player_map.get(client_id) { // Find last known state for this client if let Some(last_known_sequence) = client_states.get_last_sequence(*client_id) { // Find appropriate snapshot to roll back to if let Some(snapshot) = find_appropriate_snapshot(&state_history, last_known_sequence) { // Trigger rollback just for this client rollback_events.send(RollbackEvent { snapshot: snapshot.clone(), reason: RollbackReason::ClientReconnection, affected_clients: vec![*client_id], }); } } } } } } }
Implementation Example
Complete Rollback Process
This example shows a complete rollback process after detecting a state divergence:
#![allow(unused)] fn main() { /// System to detect and handle state divergences pub fn detect_state_divergences( mut commands: Commands, mut state_checksums: EventReader<StateChecksumEvent>, state_history: Res<StateHistory>, server: Option<Res<GameServer>>, mut rollback_events: EventWriter<RollbackEvent>, ) { // Only run on server if server.is_none() { return; } for checksum_event in state_checksums.read() { // Compare client checksum with server's expected checksum if checksum_event.client_checksum != checksum_event.expected_checksum { info!("State divergence detected for client {:?} at sequence {}", checksum_event.client_id, checksum_event.sequence_id); // Find appropriate snapshot to roll back to if let Some(snapshot) = find_rollback_snapshot(&state_history, checksum_event.sequence_id) { // Trigger rollback for the affected client rollback_events.send(RollbackEvent { snapshot: snapshot.clone(), reason: RollbackReason::StateDivergence, affected_clients: vec![checksum_event.client_id], }); // Log the rollback event info!("Initiating rollback to sequence {} for client {:?}", snapshot.sequence_id, checksum_event.client_id); } } } } /// Find an appropriate snapshot for rollback fn find_rollback_snapshot(history: &StateHistory, divergence_sequence: u64) -> Option<&StateSnapshot> { // Find the most recent snapshot before the divergence history.snapshots .iter() .rev() .find(|snapshot| snapshot.sequence_id < divergence_sequence) } /// Apply a rollback pub fn apply_rollback( mut commands: Commands, mut game_state: ResMut<GameState>, mut global_rng: ResMut<GlobalEntropy<WyRand>>, mut rollback_events: EventReader<RollbackEvent>, action_history: Res<ActionHistory>, ) { for event in rollback_events.read() { // 1. Restore game state from snapshot deserialize_game_state(&mut game_state, &event.snapshot.game_state); // 2. Restore RNG state global_rng.deserialize_state(&event.snapshot.rng_state) .expect("Failed to restore RNG state"); // 3. Find actions that need to be replayed let actions_to_replay = action_history.get_actions_after(event.snapshot.sequence_id); // 4. Replay actions for action in actions_to_replay { apply_action(&mut commands, &mut game_state, &mut global_rng, action.clone()); } // 5. Notify clients of the rollback for client_id in &event.affected_clients { commands.add(SendRollbackNotification { client_id: *client_id, snapshot: event.snapshot.clone(), reason: event.reason, }); } } } }
Handling Randomized Actions During Rollback
Special consideration for randomized actions like card shuffling:
#![allow(unused)] fn main() { /// Apply an action during rollback replay fn apply_action( commands: &mut Commands, game_state: &mut GameState, global_rng: &mut GlobalEntropy<WyRand>, action: ActionRecord, ) { match &action.action { GameAction::ShuffleLibrary { player, library } => { // For randomized actions, we need to ensure deterministic outcomes if let Ok(mut player_rng) = players.get_mut(action.player_id) { // Important: Use the RNG in a consistent way let mut library_entity = *library; let mut library_comp = game_state.get_library_mut(library_entity); // Deterministic shuffle using the player's RNG component library_comp.shuffle_with_rng(&mut player_rng.rng); } }, GameAction::FlipCoin { player } => { // Another example of randomized action if let Ok(mut player_rng) = players.get_mut(action.player_id) { // The random result will be the same as the original action // if the RNG state is properly restored let result = player_rng.rng.gen_bool(0.5); // Apply the result game_state.record_coin_flip(*player, result); } }, // Handle other action types _ => { // Apply non-randomized actions normally game_state.apply_action(&action.action); } } } }
Real-World Considerations
In practice, a rollback system needs to balance several considerations:
- Snapshot Frequency: More frequent snapshots use more memory but allow more precise rollbacks
- Rollback Visibility: How visible should rollbacks be to players?
- Partial vs. Full Rollbacks: Sometimes only a portion of the state needs rollback
- Action Batching: Batch multiple actions to minimize rollback frequency
- Bandwidth Costs: State synchronization requires bandwidth - optimize it
Optimizing for MTG Commander
For MTG Commander specifically:
- Take snapshots at natural game boundaries (turn changes, phase changes)
- Use incremental state updates between major decision points
- Maintain separate RNG state for "hidden information" actions like shuffling
- Prioritize server authority for rule enforcement and dispute resolution
- Enable client prediction for responsive UI during network hiccups
Deterministic Logic
Bevy Replicon Integration for Rollback with RNG State Management
This document details the integration of bevy_replicon with our rollback system, focusing on maintaining RNG state consistency across the network.
Table of Contents
- Introduction
- Replicon and RNG Integration
- Resources and Components
- Systems Integration
- State Preservation and Recovery
- Implementation Examples
- Performance Considerations
- Testing Guidelines
- Snapshot System Integration
Introduction
bevy_replicon is a lightweight, ECS-friendly networking library that provides replication for Bevy games. While it handles much of the complexity of network synchronization, maintaining deterministic RNG state during rollbacks requires additional mechanisms.
This document outlines how we extend bevy_replicon to handle RNG state management during network disruptions, ensuring all clients maintain identical random number sequences after recovery.
Replicon and RNG Integration
The key challenge is integrating bevy_replicon's entity replication with our RNG management system, particularly when:
- Replicating randomized game actions
- Handling rollbacks after connection interruptions
- Ensuring newly connected clients receive the correct RNG state
- Maintaining determinism during complex game scenarios
Our solution uses bevy_replicon's server-authoritative model but adds RNG state tracking and distribution mechanisms.
Architectural Overview
┌─────────────────────────────────────────────────────────────────────────┐
│ SERVER │
│ │
│ ┌───────────────┐ ┌──────────────────┐ ┌────────────────────┐ │
│ │ │ │ │ │ │ │
│ │ REPLICON │────▶│ RNG STATE │────▶│ GAME STATE │ │
│ │ SERVER │ │ MANAGER │ │ MANAGER │ │
│ │ │ │ │ │ │ │
│ └───────┬───────┘ └─────────┬────────┘ └────────┬───────────┘ │
│ │ │ │ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌───────────────┐ ┌──────────────────┐ ┌────────────────────┐ │
│ │ │ │ │ │ │ │
│ │ REPLICON │────▶│ ROLLBACK │◀────│ SEQUENCE │ │
│ │ REPLICATION │ │ COORDINATOR │ │ TRACKER │ │
│ │ │ │ │ │ │ │
│ └───────────────┘ └──────────────────┘ └────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
│
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ CLIENT │
│ │
│ ┌───────────────┐ ┌──────────────────┐ ┌────────────────────┐ │
│ │ │ │ │ │ │ │
│ │ REPLICON │────▶│ RNG STATE │────▶│ GAME STATE │ │
│ │ CLIENT │ │ APPLIER │ │ RECEIVER │ │
│ │ │ │ │ │ │ │
│ └───────┬───────┘ └─────────┬────────┘ └────────┬───────────┘ │
│ │ │ │ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌───────────────┐ ┌──────────────────┐ ┌────────────────────┐ │
│ │ │ │ │ │ │ │
│ │ LOCAL │────▶│ PREDICTION │◀────│ HISTORY │ │
│ │ RNG MANAGER │ │ RECONCILIATION │ │ TRACKER │ │
│ │ │ │ │ │ │ │
│ └───────────────┘ └──────────────────┘ └────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Resources and Components
Core Resources
#![allow(unused)] fn main() { /// Resource that tracks RNG state for replication #[derive(Resource)] pub struct RngReplicationState { /// Current global RNG state pub global_state: Vec<u8>, /// Player-specific RNG states pub player_states: HashMap<Entity, Vec<u8>>, /// Sequence number for the latest RNG state update pub sequence: u64, /// Timestamp of the last update pub last_update: f32, /// Flag indicating the state has changed pub dirty: bool, } /// Resource for rollback checkpoints with RNG state #[derive(Resource)] pub struct RollbackCheckpoints { /// Checkpoints with sequence IDs as keys pub checkpoints: BTreeMap<u64, RollbackCheckpoint>, /// Maximum number of checkpoints to maintain pub max_checkpoints: usize, } /// Structure for a single rollback checkpoint #[derive(Clone, Debug, Serialize, Deserialize)] pub struct RollbackCheckpoint { /// Checkpoint sequence ID pub sequence_id: u64, /// Timestamp of the checkpoint pub timestamp: f32, /// Global RNG state pub global_rng_state: Vec<u8>, /// Player-specific RNG states pub player_rng_states: HashMap<Entity, Vec<u8>>, /// Replicated entities snapshot pub replicated_entities: Vec<EntityData>, } /// Replicon channel for RNG synchronization #[derive(Default)] pub struct RngSyncChannel; /// Extension for RepliconServer to handle RNG state impl RepliconServerExt for RepliconServer { /// Send RNG state to a specific client fn send_rng_state(&mut self, client_id: ClientId, rng_state: &RngReplicationState) { let message = RngStateMessage { global_state: rng_state.global_state.clone(), player_states: rng_state.player_states.clone(), sequence: rng_state.sequence, timestamp: rng_state.last_update, }; self.send_message(client_id, RngSyncChannel, bincode::serialize(&message).unwrap()); } /// Broadcast RNG state to all clients fn broadcast_rng_state(&mut self, rng_state: &RngReplicationState) { let message = RngStateMessage { global_state: rng_state.global_state.clone(), player_states: rng_state.player_states.clone(), sequence: rng_state.sequence, timestamp: rng_state.last_update, }; self.broadcast_message(RngSyncChannel, bincode::serialize(&message).unwrap()); } } }
Components for Entity Tracking
#![allow(unused)] fn main() { /// Component to flag an entity as having randomized behavior #[derive(Component, Reflect, Default)] pub struct RandomizedBehavior { /// The last RNG sequence ID used for this entity pub last_rng_sequence: u64, /// Whether this entity has pending randomized actions pub has_pending_actions: bool, } /// Component for player-specific RNG #[derive(Component, Reflect)] pub struct PlayerRng { /// Sequence of the last RNG state pub sequence: u64, /// Whether this RNG is remote (on another client) pub is_remote: bool, } }
Snapshot System Integration
For detailed information about how the replicon rollback system integrates with the snapshot system, please refer to the centralized Snapshot System documentation:
The snapshot system provides the serialization and deserialization capabilities needed for the rollback system, while the replicon integration described in this document ensures proper handling of RNG state during network operations.
Key integration points include:
- RNG State Capture: The snapshot system captures RNG state alongside other game state
- Deterministic Rollback: Integration ensures that RNG sequences remain identical after rollback
- Client Synchronization: New clients receive correct RNG state as part of their initial snapshot
- Networked Events: Random events are processed deterministically across all clients
For more detailed implementation examples of how to use these systems together, see the Implementation Examples section below and the Network Snapshot Testing section in the snapshot system documentation.
Systems Integration
Server-Side Systems
#![allow(unused)] fn main() { /// Plugin that integrates bevy_replicon with our RNG and rollback systems pub struct RepliconRngRollbackPlugin; impl Plugin for RepliconRngRollbackPlugin { fn build(&self, app: &mut App) { // Register network channel app.register_network_channel::<RngSyncChannel>(ChannelConfig { channel_id: 100, // Use a unique channel ID mode: ChannelMode::Unreliable, }); // Add resources app.init_resource::<RngReplicationState>() .init_resource::<RollbackCheckpoints>(); // Server systems app.add_systems(Update, ( capture_rng_state, replicate_rng_state, create_rollback_checkpoints, ).run_if(resource_exists::<RepliconServer>())); // Client systems app.add_systems(Update, ( apply_rng_state_updates, handle_rollback_requests, ).run_if(resource_exists::<RepliconClient>())); } } /// System to capture RNG state for replication pub fn capture_rng_state( mut global_rng: ResMut<GlobalEntropy<WyRand>>, player_rngs: Query<(Entity, &PlayerRng)>, mut rng_state: ResMut<RngReplicationState>, time: Res<Time>, sequence: Res<SequenceTracker>, ) { // Don't update too frequently if time.elapsed_seconds() - rng_state.last_update < 1.0 { return; } // Capture global RNG state if let Some(state) = global_rng.try_serialize_state() { rng_state.global_state = state; rng_state.dirty = true; } // Capture player RNG states for (entity, _) in player_rngs.iter() { if let Some(player_rng) = player_rngs.get_component::<Entropy<WyRand>>(entity).ok() { if let Some(state) = player_rng.try_serialize_state() { rng_state.player_states.insert(entity, state); rng_state.dirty = true; } } } if rng_state.dirty { rng_state.sequence = sequence.current_sequence; rng_state.last_update = time.elapsed_seconds(); } } /// System to replicate RNG state to clients pub fn replicate_rng_state( mut server: ResMut<RepliconServer>, rng_state: Res<RngReplicationState>, ) { if rng_state.dirty { server.broadcast_rng_state(&rng_state); } } /// System to create rollback checkpoints pub fn create_rollback_checkpoints( mut checkpoints: ResMut<RollbackCheckpoints>, rng_state: Res<RngReplicationState>, time: Res<Time>, replicated_query: Query<Entity, With<Replication>>, entity_data: Res<EntityData>, ) { // Create a new checkpoint every few seconds if time.elapsed_seconds() % 5.0 < 0.1 { // Collect replicated entity data let mut entities = Vec::new(); for entity in replicated_query.iter() { if let Some(data) = entity_data.get_entity_data(entity) { entities.push(data.clone()); } } // Create checkpoint let checkpoint = RollbackCheckpoint { sequence_id: rng_state.sequence, timestamp: time.elapsed_seconds(), global_rng_state: rng_state.global_state.clone(), player_rng_states: rng_state.player_states.clone(), replicated_entities: entities, }; // Add to checkpoints checkpoints.checkpoints.insert(rng_state.sequence, checkpoint); // Prune old checkpoints while checkpoints.checkpoints.len() > checkpoints.max_checkpoints { if let Some((&oldest_key, _)) = checkpoints.checkpoints.iter().next() { checkpoints.checkpoints.remove(&oldest_key); } } } } }
Client-Side Systems
#![allow(unused)] fn main() { /// System to apply RNG state updates from server pub fn apply_rng_state_updates( mut client: ResMut<RepliconClient>, mut global_rng: ResMut<GlobalEntropy<WyRand>>, mut player_rngs: Query<(Entity, &mut PlayerRng)>, mut events: EventReader<NetworkEvent>, ) { for event in events.read() { if let NetworkEvent::Message(_, RngSyncChannel, data) = event { // Deserialize the RNG state message if let Ok(message) = bincode::deserialize::<RngStateMessage>(data) { // Apply global RNG state if !message.global_state.is_empty() { global_rng.deserialize_state(&message.global_state) .expect("Failed to deserialize global RNG state"); } // Apply player-specific RNG states for (entity, mut player_rng) in player_rngs.iter_mut() { if let Some(state) = message.player_states.get(&entity) { if let Some(rng) = player_rngs.get_component_mut::<Entropy<WyRand>>(entity).ok() { rng.deserialize_state(state).expect("Failed to deserialize player RNG state"); player_rng.sequence = message.sequence; } } } } } } } /// System to handle rollback requests pub fn handle_rollback_requests( mut client: ResMut<RepliconClient>, mut global_rng: ResMut<GlobalEntropy<WyRand>>, mut player_rngs: Query<(Entity, &mut PlayerRng)>, mut events: EventReader<NetworkEvent>, mut commands: Commands, ) { for event in events.read() { if let NetworkEvent::Message(_, RollbackChannel, data) = event { // Deserialize the rollback message if let Ok(message) = bincode::deserialize::<RollbackMessage>(data) { // Apply global RNG state from the checkpoint if !message.checkpoint.global_rng_state.is_empty() { global_rng.deserialize_state(&message.checkpoint.global_rng_state) .expect("Failed to deserialize checkpoint RNG state"); } // Apply player-specific RNG states from the checkpoint for (entity, mut player_rng) in player_rngs.iter_mut() { if let Some(state) = message.checkpoint.player_rng_states.get(&entity) { if let Some(rng) = player_rngs.get_component_mut::<Entropy<WyRand>>(entity).ok() { rng.deserialize_state(state).expect("Failed to deserialize player RNG state"); player_rng.sequence = message.checkpoint.sequence_id; } } } // Restore entity state from checkpoint for entity_data in &message.checkpoint.replicated_entities { // Restore entity or spawn if it doesn't exist // ... } info!("Applied rollback to sequence {}", message.checkpoint.sequence_id); } } } } }
State Preservation and Recovery
The rollback process occurs in these steps:
- Detection: Server detects desynchronization (via mismatch in action results)
- Checkpoint Selection: Server selects appropriate rollback checkpoint
- Notification: Server notifies affected clients of rollback
- State Restoration: Both server and clients:
- Restore game state
- Restore RNG state
- Replay necessary actions
- Verification: Server verifies all clients are synchronized
Rollback Protocol
#![allow(unused)] fn main() { /// Enum for rollback types #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub enum RollbackType { /// Full rollback with complete state restoration Full, /// Partial rollback for specific entities only Partial, /// RNG-only rollback for randomization issues RngOnly, } /// Message for rollback requests #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RollbackMessage { /// Type of rollback pub rollback_type: RollbackType, /// Rollback checkpoint pub checkpoint: RollbackCheckpoint, /// Reason for rollback pub reason: String, } }
Implementation Examples
Example 1: Rollback After Network Interruption
#![allow(unused)] fn main() { /// System to detect and handle network interruptions pub fn handle_network_interruption( mut server: ResMut<RepliconServer>, checkpoints: Res<RollbackCheckpoints>, clients: Query<(Entity, &ClientConnection)>, time: Res<Time>, ) { // Check for clients with high latency or disconnection for (entity, connection) in clients.iter() { if connection.latency > 1.0 || !connection.connected { // Find most recent valid checkpoint if let Some((_, checkpoint)) = checkpoints.checkpoints.iter().rev().next() { // Initiate rollback for all clients let rollback_message = RollbackMessage { rollback_type: RollbackType::Full, checkpoint: checkpoint.clone(), reason: "Network interruption detected".to_string(), }; // Send to all clients server.broadcast_message(RollbackChannel, bincode::serialize(&rollback_message).unwrap()); // Apply rollback on server too apply_rollback_on_server(&rollback_message); info!("Initiated rollback due to network interruption"); } break; } } } }
Example 2: Handling Card Shuffle During Rollback
#![allow(unused)] fn main() { /// System to handle card shuffling during or after a rollback pub fn handle_shuffle_during_rollback( mut commands: Commands, mut shuffle_events: EventReader<ShuffleLibraryEvent>, mut global_rng: ResMut<GlobalEntropy<WyRand>>, player_rngs: Query<(Entity, &PlayerRng)>, libraries: Query<(Entity, &Library, &Parent)>, ) { for event in shuffle_events.read() { if let Ok((library_entity, library, parent)) = libraries.get(event.library_entity) { // Get the player entity (parent) let player_entity = parent.get(); // Get player's RNG if let Ok((_, player_rng)) = player_rngs.get(player_entity) { // Use the appropriate RNG for deterministic shuffle let mut card_indices: Vec<usize> = (0..library.cards.len()).collect(); if player_rng.is_remote { // Use global RNG for remote player to ensure consistency for i in (1..card_indices.len()).rev() { let j = global_rng.gen_range(0..=i); card_indices.swap(i, j); } } else { // Use player-specific RNG for local player if let Some(rng) = player_rngs.get_component::<Entropy<WyRand>>(player_entity).ok() { for i in (1..card_indices.len()).rev() { let j = rng.gen_range(0..=i); card_indices.swap(i, j); } } } // Apply shuffle result // ... info!("Performed deterministic shuffle during/after rollback"); } } } } }
Performance Considerations
When implementing RNG state management with bevy_replicon and rollbacks, consider these performance factors:
-
RNG State Size:
- WyRand has a compact 8-byte state, ideal for frequent replication
- More complex PRNGs may have larger states, increasing network overhead
-
Checkpoint Frequency:
- More frequent checkpoints = better recovery granularity but higher overhead
- Recommended: 5-10 second intervals for most games
-
Selective Replication:
- Only replicate RNG state when it changes significantly
- Consider checksums to detect state changes efficiently
-
Bandwidth Usage:
- Use the appropriate channel mode (reliable for critical RNG updates)
- Batch RNG updates with other state replication when possible
-
Memory Overhead:
- Limit maximum checkpoints based on available memory (10-20 is reasonable)
- Use sliding window approach to discard old checkpoints
Testing Guidelines
For effective testing of replicon-based RNG rollback, follow these approaches:
-
Determinism Tests:
- Verify identical seeds produce identical sequences on all clients
- Test saving and restoring RNG state produces identical future values
-
Network Disruption Tests:
- Simulate connection drops to trigger rollback
- Verify game state remains consistent after recovery
-
Performance Tests:
- Measure impact of RNG state replication on bandwidth
- Profile checkpoint creation and restoration overhead
-
Integration Tests:
- Test complex game scenarios like multi-player card shuffling
- Verify random outcomes remain consistent across network boundaries
For detailed testing examples, see the RNG Synchronization Tests document.
By following these guidelines, you can create a robust integration between bevy_replicon, our rollback system, and RNG state management that maintains deterministic behavior even during network disruptions.
Action Broadcasting
Latency Compensation
Player Departure Handling
This section covers the handling of player departures in networked gameplay, focusing on maintaining game integrity when players disconnect or leave a game in progress.
Overview
In multiplayer games, especially Commander format, player departures can significantly impact gameplay. This system handles:
- Graceful disconnections (player intentionally leaves)
- Unexpected disconnections (network failures, crashes)
- Temporary disconnections with reconnection potential
- Permanent departures that require game state adjustments
Components
Departure Handling
Detailed implementation of how player departures are processed, including:
- State preservation for potential reconnection
- Game state adjustments when a player permanently leaves
- Handling of in-progress actions when a player disconnects
- Object ownership transfer upon player departure
- AI takeover options for departed players
Integration
This system integrates closely with:
- State Management for game state consistency
- Rollback System for handling in-progress actions
- Synchronization for maintaining consistency across clients
Game Departure Handling
This document describes how the system handles players leaving an active Commander game, whether through quitting, being kicked, or disconnection. Handling player departures properly is crucial for maintaining game integrity and player experience.
Table of Contents
Overview
In a multiplayer Commander game, players may leave for various reasons, and the game must handle these departures gracefully. The system differentiates between different types of departures and provides appropriate mechanisms for each.
┌───────────────────┐
│ │
│ Active Game │◄───── New Player Joining (Spectator)
│ │
└─────────┬─────────┘
│
▼
┌───────────────────┐
│ Player Departure │
└─────────┬─────────┘
│
▼
┌───────────────────┬────────────────┬───────────────────┐
│ │ │ │
│ Voluntary Quit │ Kicked by Host │ Disconnection │
│ │ │ │
└─────────┬─────────┴────────┬───────┴─────────┬─────────┘
│ │ │
▼ ▼ ▼
┌───────────────────┐ ┌──────────────┐ ┌───────────────────┐
│ Return to Menu │ │ Menu + Notif │ │ Reconnect Window │
└───────────────────┘ └──────────────┘ └─────────┬─────────┘
│
▼
┌───────────────────┐
│ Success/Failure │
└───────────────────┘
Departure Scenarios
Voluntary Quitting
When a player intentionally quits a game:
- Player selects "Quit Game" from the pause menu
- A confirmation dialog appears
- Upon confirmation, a quit message is sent to the server
- The server processes the departure and notifies other players
- Game state is updated to mark the player as departed
- The quitting player returns to the main menu
#![allow(unused)] fn main() { /// System to handle quit game request fn handle_quit_game( mut interaction_query: Query<&Interaction, (Changed<Interaction>, With<QuitGameButton>)>, mut confirmation_dialog: ResMut<ConfirmationDialog>, ) { for interaction in &mut interaction_query { if *interaction == Interaction::Pressed { // Show confirmation dialog confirmation_dialog.show( "Quit Game?", "Are you sure you want to quit this game? Your progress will be lost.", ConfirmationAction::QuitGame, ); } } } /// System to process quit confirmation fn process_quit_confirmation( mut confirmation_events: EventReader<ConfirmationResponse>, mut game_connection: ResMut<GameConnection>, mut next_state: ResMut<NextState<GameMenuState>>, ) { for event in confirmation_events.read() { if event.action == ConfirmationAction::QuitGame && event.confirmed { // Send quit notification to server game_connection.send_departure_notification(DepartureReason::Voluntary); // Return to main menu next_state.set(GameMenuState::MainMenu); } } } }
Being Kicked
When a player is kicked by the host:
- Host opens player menu and selects "Kick Player"
- A confirmation dialog appears for the host
- Upon confirmation, a kick message is sent to the server
- The server validates the request and processes the kick
- The kicked player receives a notification
- The kicked player's UI transitions to the main menu with a message
- Other players are notified that the player was kicked
#![allow(unused)] fn main() { /// Host-side kick request fn handle_kick_player_request( mut interaction_query: Query<(&Interaction, &PlayerTarget), (Changed<Interaction>, With<KickPlayerButton>)>, mut confirmation_dialog: ResMut<ConfirmationDialog>, ) { for (interaction, target) in &mut interaction_query { if *interaction == Interaction::Pressed { // Show confirmation dialog for host confirmation_dialog.show( "Kick Player?", &format!("Are you sure you want to kick {}?", target.name), ConfirmationAction::KickPlayer(target.player_id.clone()), ); } } } /// System to handle being kicked from a game fn handle_being_kicked_from_game( mut kick_events: EventReader<KickedFromGameEvent>, mut commands: Commands, mut next_state: ResMut<NextState<GameMenuState>>, ) { for event in kick_events.read() { // Display kicked message commands.spawn(KickedGameNotificationUI { reason: event.reason.clone(), }); // Return to main menu next_state.set(GameMenuState::MainMenu); } } }
Disconnection
When a player disconnects unexpectedly:
- The server detects a connection drop
- The server keeps the player's game state for a reconnection window
- Other players see the disconnected player's status change
- If the player reconnects within the window, they rejoin seamlessly
- If the reconnection window expires, the player is fully removed
#![allow(unused)] fn main() { /// System to handle disconnections in active games fn handle_game_disconnection( mut disconnection_events: EventReader<PlayerDisconnectedGameEvent>, mut game_state: ResMut<GameState>, mut player_query: Query<(&mut PlayerComponent, &NetworkId)>, ) { for event in disconnection_events.read() { // Find and update player state for (mut player, network_id) in &mut player_query { if network_id.0 == event.player_id { player.connection_status = ConnectionStatus::Disconnected; player.reconnection_timer = Some(GAME_RECONNECTION_WINDOW); // Pause the player's turn if active if game_state.active_player == event.player_id { game_state.active_turn_paused = true; game_state.pause_reason = PauseReason::PlayerDisconnected; } break; } } } } /// System to handle reconnection windows fn update_reconnection_timers( time: Res<Time>, mut game_state: ResMut<GameState>, mut player_query: Query<(&mut PlayerComponent, &NetworkId)>, mut commands: Commands, ) { for (mut player, network_id) in &mut player_query { if let Some(timer) = &mut player.reconnection_timer { *timer -= time.delta_seconds(); if *timer <= 0.0 { // Reconnection window expired, remove player player.connection_status = ConnectionStatus::Left; player.reconnection_timer = None; // If it was their turn, pass to next player if game_state.active_player == network_id.0 { commands.add(PassTurn); } } } } } }
Game State Preservation
When a player leaves a Commander game, their game state is handled according to the format rules:
-
Permanent Departure: If a player quits, is kicked, or their reconnection window expires:
- Their cards remain in play until they would naturally leave the battlefield
- Their life total is set to 0 for Commander damage calculations
- Their turns are skipped
- They are no longer a valid target for spells/abilities that target players
- Effects they controlled continue to function as normal until they expire
-
Temporary Disconnection: If a player disconnects but can reconnect:
- Their game state is fully preserved
- Their turn is paused if it was active
- Other players can continue to play
- The game resumes normally once they reconnect
#![allow(unused)] fn main() { /// Handle permanent player departure fn handle_permanent_departure( mut commands: Commands, departure_event: Res<PlayerDepartureEvent>, mut game_state: ResMut<GameState>, player_query: Query<(Entity, &NetworkId, &PlayerComponent)>, card_query: Query<(Entity, &Owner, &Zone)>, ) { // Find the departed player's entity for (player_entity, network_id, player) in player_query.iter() { if network_id.0 == departure_event.player_id { // Mark player as departed in game state commands.entity(player_entity).insert(DepartedPlayer); // Set life total to 0 for commander damage calculations commands.entity(player_entity).insert(LifeTotal(0)); // Handle active effects handle_departed_player_effects(commands, player_entity, &card_query); // If it was their turn, pass to next player if game_state.active_player == network_id.0 { commands.add(PassTurn); } break; } } } }
UI Experience
The user interface handles departures with appropriate feedback:
For the Departing Player
- Voluntary Quit: Simple transition to main menu
- Kicked: Notification explaining they were kicked before returning to main menu
- Disconnection: Reconnection attempts with progress indicators
For Remaining Players
- Player Quit: Notification of player departure
- Player Kicked: Notification of player being kicked
- Disconnection: Status indicator showing disconnected state and reconnection attempt
#![allow(unused)] fn main() { /// UI component for disconnection status #[derive(Component)] pub struct DisconnectionUI { /// Disconnected player name player_name: String, /// Time remaining in reconnection window time_remaining: f32, } /// System to update disconnection UI fn update_disconnection_ui( mut disconnection_query: Query<(&mut Text, &mut DisconnectionUI)>, time: Res<Time>, ) { for (mut text, mut disconnection) in &mut disconnection_query { // Update remaining time disconnection.time_remaining -= time.delta_seconds(); // Update display text text.sections[0].value = format!( "{} disconnected. Reconnecting... ({}s)", disconnection.player_name, disconnection.time_remaining as u32 ); // Change text color based on time remaining if disconnection.time_remaining < 10.0 { text.sections[0].style.color = Color::RED; } } } }
Reconnection Flow
The reconnection process involves several steps:
- Detection: Client detects lost connection to the game server
- Retry: Client attempts to reconnect automatically
- Authentication: Upon reconnection, client provides game session token
- State Sync: Server sends complete game state to the reconnected client
- Resumption: Game continues with the reconnected player
#![allow(unused)] fn main() { /// Reconnection sequence fn handle_reconnection( mut connection: ResMut<GameConnection>, mut game_state: ResMut<LocalGameState>, mut next_state: ResMut<NextState<ReconnectionState>>, ) { match *next_state.get() { ReconnectionState::Disconnected => { // Attempt to reconnect if connection.attempt_reconnect() { next_state.set(ReconnectionState::Connecting); } } ReconnectionState::Connecting => { // Check connection status if connection.is_connected() { next_state.set(ReconnectionState::Authenticating); } else if connection.attempts_exhausted() { next_state.set(ReconnectionState::Failed); } } ReconnectionState::Authenticating => { // Authenticate with game session if connection.authenticate_session() { next_state.set(ReconnectionState::SyncingState); } } ReconnectionState::SyncingState => { // Receive and process game state if game_state.is_synchronized() { next_state.set(ReconnectionState::Completed); } } ReconnectionState::Completed => { // Resume game next_state.set(ReconnectionState::None); } ReconnectionState::Failed => { // Return to main menu with error // ... } _ => {} } } }
Implementation
Message Types
#![allow(unused)] fn main() { /// Game departure notification #[derive(Serialize, Deserialize, Clone, Debug)] pub struct GameDepartureNotification { /// Player ID that is departing pub player_id: String, /// Reason for departure pub reason: DepartureReason, /// Timestamp of departure pub timestamp: f64, } /// Reasons for player departure #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub enum DepartureReason { /// Player chose to leave Voluntary, /// Player was kicked by the host Kicked, /// Player disconnected unexpectedly Disconnected, /// Server closed the game ServerClosure, /// Game ended normally GameCompleted, } /// Reconnection request #[derive(Serialize, Deserialize, Clone, Debug)] pub struct ReconnectionRequest { /// Game ID to reconnect to pub game_id: String, /// Player ID reconnecting pub player_id: String, /// Authentication token pub auth_token: String, /// Last known game tick pub last_tick: u64, } }
Systems
The implementation includes dedicated systems to handle the various player departure scenarios:
#![allow(unused)] fn main() { /// Register game departure systems pub fn register_departure_systems(app: &mut App) { app .add_event::<PlayerDepartureEvent>() .add_event::<KickedFromGameEvent>() .add_event::<PlayerDisconnectedGameEvent>() .add_event::<ReconnectionEvent>() .add_systems(Update, ( handle_quit_game, process_quit_confirmation, handle_kick_player_request, handle_being_kicked_from_game, handle_game_disconnection, update_reconnection_timers, handle_permanent_departure, update_disconnection_ui, handle_reconnection, )); } }
These systems work together to provide a seamless experience for players during game departures, ensuring the game remains playable and enjoyable for those who remain.
Game State Synchronization
This section covers the synchronization mechanisms used to ensure consistent game state across all clients in a networked Magic: The Gathering Commander game.
Overview
State synchronization is critical for maintaining a fair and consistent gameplay experience. Our implementation focuses on:
- Deterministic gameplay logic
- Efficient state updates
- Handling of random elements
- Resolution of inconsistencies
- Hidden information management
Components
RNG Rollback
Implementation details for synchronizing random number generation across clients, including:
- Deterministic RNG sequence generation
- Seed management
- Rollback capabilities for RNG state
- Verification mechanisms for RNG consistency
Integration
This system integrates closely with:
- State Management for overall game state
- Rollback System for handling state corrections
- Action Broadcasting for communicating state changes
- Latency Compensation for responsive gameplay despite network delays
Testing
Comprehensive testing of synchronization mechanisms is covered in the Testing section, with specific focus on:
RNG Synchronization and Rollback Integration
This document explains how the random number generator (RNG) synchronization system integrates with the state rollback mechanism to maintain consistent game state across network disruptions.
Table of Contents
- Overview
- Challenges
- Integration Architecture
- Implementation Details
- Example Scenarios
- Performance Considerations
Overview
Card games like Magic: The Gathering require randomization for shuffling libraries, coin flips, dice rolls, and "random target" selection. In a networked environment, these random operations must produce identical results across all clients despite network disruptions. Our solution combines the RNG synchronization system with the state rollback mechanism to ensure consistent gameplay.
Challenges
Several challenges must be addressed:
- Deterministic Recovery: After a network disruption, random operations must produce the same results as before
- Hidden Information: RNG state must be preserved without revealing hidden information (like library order)
- Partial Rollbacks: Some clients may need to roll back while others do not
- Performance: RNG state serialization and transmission must be efficient
- Cheating Prevention: The system must prevent manipulation of random outcomes
Integration Architecture
The integration of RNG synchronization with the rollback system follows this architecture:
┌────────────────────┐ ┌───────────────────┐ ┌────────────────────┐
│ │ │ │ │ │
│ RNG STATE SYSTEM │◄────┤ ROLLBACK SYSTEM │────►│ GAME STATE SYSTEM │
│ │ │ │ │ │
└───────┬────────────┘ └─────────┬─────────┘ └─────────┬──────────┘
│ │ │
│ │ │
▼ ▼ ▼
┌────────────────────┐ ┌───────────────────┐ ┌────────────────────┐
│ │ │ │ │ │
│ HISTORY TRACKER │◄────┤ SEQUENCE TRACKER │────►│ ACTION PROCESSOR │
│ │ │ │ │ │
└────────────────────┘ └───────────────────┘ └────────────────────┘
Key Components
- RNG State System: Manages the global and player-specific RNG states
- Rollback System: Handles state rollbacks due to network disruptions
- Game State System: Maintains the authoritative game state
- History Tracker: Records RNG states at key sequence points
- Sequence Tracker: Assigns sequence IDs to game actions
- Action Processor: Applies game actions deterministically
Implementation Details
RNG State Snapshots
For every game state snapshot, we also capture the corresponding RNG state:
#![allow(unused)] fn main() { /// System to capture RNG state with game state snapshots pub fn capture_rng_with_state_snapshot( mut state_history: ResMut<StateHistory>, global_rng: Res<GlobalEntropy<WyRand>>, players: Query<(Entity, &PlayerRng)>, ) { if let Some(current_snapshot) = state_history.snapshots.last_mut() { // Save global RNG state if let Some(global_state) = global_rng.try_serialize_state() { current_snapshot.rng_state = global_state; } // Save player-specific RNG states let mut player_rng_states = HashMap::new(); for (entity, player_rng) in players.iter() { if let Some(state) = player_rng.rng.try_serialize_state() { player_rng_states.insert(entity, state); } } current_snapshot.player_rng_states = player_rng_states; } } }
Randomized Action Handling
Randomized actions require special attention during rollbacks:
#![allow(unused)] fn main() { /// Apply a randomized action with RNG state consistency pub fn apply_randomized_action( action: &GameAction, rng_state: Option<&[u8]>, global_rng: &mut GlobalEntropy<WyRand>, player_rngs: &mut Query<&mut PlayerRng>, ) -> ActionResult { // If we have a saved RNG state for this action, restore it first if let Some(state) = rng_state { global_rng.deserialize_state(state).expect("Failed to restore RNG state"); } match action { GameAction::ShuffleLibrary { player, library } => { // Get the player's RNG component if let Ok(mut player_rng) = player_rngs.get_mut(*player) { // Perform the shuffle using the player's RNG // This will produce the same result if the RNG state is the same // ... ActionResult::Success } else { ActionResult::PlayerNotFound } }, // Handle other randomized actions... _ => ActionResult::NotRandomized, } } }
Rollback With RNG Recovery
During a rollback, both game state and RNG state are restored:
#![allow(unused)] fn main() { /// System to perform a rollback with RNG state recovery pub fn perform_rollback_with_rng( mut game_state: ResMut<GameState>, mut global_rng: ResMut<GlobalEntropy<WyRand>>, mut player_rngs: Query<(Entity, &mut PlayerRng)>, rollback_event: Res<RollbackEvent>, ) { // Restore game state deserialize_game_state(&mut game_state, &rollback_event.snapshot.game_state); // Restore global RNG state global_rng.deserialize_state(&rollback_event.snapshot.rng_state) .expect("Failed to restore global RNG state"); // Restore player-specific RNG states for (entity, mut player_rng) in player_rngs.iter_mut() { if let Some(state) = rollback_event.snapshot.player_rng_states.get(&entity) { player_rng.rng.deserialize_state(state) .expect("Failed to restore player RNG state"); } } // Log the rollback info!("Performed rollback to sequence {} with RNG state recovery", rollback_event.snapshot.sequence_id); } }
Example Scenarios
Scenario 1: Library Shuffle During Network Disruption
- Player A initiates a library shuffle
- Network disruption occurs during processing
- System detects the disruption and initiates rollback
- RNG state from before the shuffle is restored
- Shuffle action is replayed with identical RNG state
- All clients observe the same shuffle outcome despite the disruption
#![allow(unused)] fn main() { // Handling a shuffle during network disruption pub fn handle_shuffle_during_disruption( mut commands: Commands, mut game_state: ResMut<GameState>, mut global_rng: ResMut<GlobalEntropy<WyRand>>, mut player_rngs: Query<&mut PlayerRng>, mut rollback_events: EventReader<RollbackEvent>, action_history: Res<ActionHistory>, ) { for event in rollback_events.read() { // Restore game and RNG state perform_rollback_with_rng(&mut game_state, &mut global_rng, &mut player_rngs, event); // Get actions to replay let actions = action_history.get_actions_after(event.snapshot.sequence_id); // Replay actions deterministically for action in actions { // If this is a shuffle action, it will produce the same result // because the RNG state has been restored apply_action(&mut commands, &mut game_state, &mut global_rng, &mut player_rngs, &action); } } } }
Scenario 2: Client Reconnection After Multiple Random Events
- Client disconnects during a game with several random actions
- Random actions continue to occur (coin flips, shuffles, etc.)
- Client reconnects after several actions
- System identifies the last confirmed sequence point for the client
- Rollback state is sent with corresponding RNG state
- Client replays all actions, producing identical random results
- Client's game state is now synchronized with the server
Performance Considerations
Integrating RNG with rollbacks introduces performance considerations:
-
RNG State Size: RNG state serialization should be compact
- WyRand RNG state is typically only 8 bytes
- Avoid large RNG algorithms for frequent serialization
-
Selective Snapshots: Not every action needs RNG state preservation
- Only save RNG state before randomized actions
- Use sequence IDs to correlate RNG states with actions
-
Batched Updates: Group randomized actions to minimize state snapshots
- Example: When shuffling multiple permanents, capture RNG once
-
Compressed History: Use a sliding window approach for history
- Discard old RNG states when they're no longer needed
- Keep only enough history for realistic rollback scenarios
-
Optimized Serialization: Use efficient binary serialization
- Consider custom serialization for RNG state if needed
- Avoid JSON or other verbose formats for RNG state
Implementation Notes
- The RNG synchronization system must be initialized before the rollback system
- All random operations must use the synchronized RNG, never
thread_rng()
or other sources - Player-specific operations should use player-specific RNG components
- RNG state should be included in regular network synchronization
- Client reconnection should always include RNG state restoration
Comprehensive Testing Guide for MTG Commander Online
This document serves as the central index for all testing documentation related to our networked MTG Commander implementation. It provides an overview of our testing strategy and links to detailed documentation for specific testing areas.
Core Principles
Our testing strategy for the online MTG Commander implementation is built on the following core principles:
- Comprehensive Coverage: Testing all aspects of the system, from individual components to full end-to-end gameplay
- Realism: Simulating real-world conditions, including varied network environments and player behaviors
- Automation: Maximizing the use of automated testing to enable frequent regression testing
- Game Rule Compliance: Ensuring the implementation adheres to all Commander format rules
- Security: Verifying that hidden information remains appropriately hidden
- Performance: Validating that the system functions well under various loads and conditions
Testing Documentation Structure
Document | Description |
---|---|
Core Testing Strategy | Outlines the fundamental approach to testing the networking implementation |
Advanced Testing Strategies | Covers specialized testing approaches for Commander-specific needs |
Integration Testing | Details testing at the boundary between networking and game engine |
Security Testing | Approaches for testing information hiding and anti-cheat mechanisms |
Testing Types
Unit Testing
Unit tests focus on individual components in isolation:
- Networking protocol components
- State synchronization mechanisms
- Game rule implementation
- Command processing
Integration Testing
Integration tests verify components work together correctly:
- Networking and game state management
- Client/server communication
- Action validation and execution
- Priority and turn handling
System Testing
System tests examine the complete system's functionality:
- Full game scenarios
- Multiple players
- Complete turn cycles
- Commander-specific rules
Network Simulation Testing
Tests under various network conditions:
- High latency
- Packet loss
- Jitter
- Bandwidth limitations
- Server/client disconnection and reconnection
Test Implementation Guidance
When implementing tests, follow these guidelines:
- Test Isolation: Each test should run independently without relying on state from other tests
- Determinism: Tests should produce consistent results when run multiple times with the same inputs
- Clear Assertions: Use descriptive assertion messages that explain what is being tested and why it failed
- Comprehensive Verification: Verify all relevant aspects of state after actions, not just one element
- Cleanup: Tests should clean up after themselves to avoid interfering with other tests
Test Data Management
Standard test fixtures are available for:
- Player configurations
- Deck compositions
- Board states
- Game scenarios
Use the TestDataRepository
to access these fixtures:
#![allow(unused)] fn main() { // Example of using test fixtures #[test] fn test_combat_interaction() { let mut app = setup_test_app(); // Load a predefined mid-game state with creatures let test_state = TestDataRepository::load_fixture("mid_game_combat_state"); setup_game_state(&mut app, &test_state); // Execute test // ... } }
Continuous Integration
Our CI pipeline automatically runs the following test suites:
- Unit Tests: On every push and pull request
- Integration Tests: On every push and pull request
- System Tests: On every push to main or develop branches
- Security Tests: Nightly on develop branch
- Network Simulation Tests: Nightly on develop branch
Test results are available in the CI dashboard, including:
- Test pass/fail status
- Performance benchmarks
- Coverage reports
- Network simulation metrics
Local Testing Workflow
To run tests locally:
# Run unit tests
cargo test networking::unit
# Run integration tests
cargo test networking::integration
# Run system tests
cargo test networking::system
# Run security tests
cargo test networking::security
# Run network simulation tests
cargo test networking::simulation
For more detailed output:
cargo test networking::integration -- --nocapture --test-threads=1
Additional Testing Resources
Contributing New Tests
When adding new tests:
- Identify the appropriate category for your test
- Follow the existing naming conventions
- Add detailed comments explaining the test purpose and expected behavior
- Update test documentation if adding new test categories
- Ensure tests run within a reasonable timeframe
By following this comprehensive testing strategy, we can ensure our networked MTG Commander implementation is robust, performant, and faithful to the rules of the game. Our testing suite provides confidence that the game will work correctly across a variety of real-world conditions and player interactions.
Networking Testing Documentation
This section provides comprehensive documentation on testing methodologies for our networked MTG Commander game engine.
Testing Overview
Testing networked applications presents unique challenges due to:
- Variable Network Conditions: Latency, packet loss, and disconnections
- State Synchronization: Ensuring all clients see the same game state
- Randomization Consistency: Maintaining deterministic behavior across network boundaries
- Security Concerns: Preventing cheating and unauthorized access
Our testing approach addresses these challenges through a multi-layered strategy, combining unit tests, integration tests, and end-to-end tests with specialized tools for network simulation.
Testing Categories
Unit Tests
Unit tests verify individual components and systems in isolation:
Integration Tests
Integration tests verify that multiple components work together correctly:
- Client-Server Integration
- Game State Synchronization
- RNG Integration Tests
- Replicon RNG Integration Tests
End-to-End Tests
End-to-end tests verify complete game scenarios from start to finish:
Performance Tests
Performance tests measure the efficiency and scalability of our networking code:
Security Tests
Security tests verify that our game is resistant to cheating and unauthorized access:
Test Implementation Guide
When implementing tests for our networked MTG Commander game, follow these guidelines:
- Test Each Layer: Test network communication, state synchronization, and game logic separately
- Simulate Real Conditions: Use network simulators to test under realistic conditions
- Automation: Automate as many tests as possible for continuous integration
- Determinism: Ensure tests are deterministic and repeatable
- RNG Testing: Pay special attention to randomized game actions
Testing Tools
Our testing infrastructure includes these specialized tools:
- Network Simulators: Tools to simulate various network conditions
- Test Harnesses: Specialized test environments for network testing
- RNG Test Utilities: Tools for verifying random number determinism
- Benchmarking Tools: Performance measurement utilities
Key Test Scenarios
Ensure these critical scenarios are thoroughly tested:
- Client Connection/Disconnection: Test proper handling of clients joining and leaving
- State Synchronization: Verify all clients see the same game state
- Randomized Actions: Test that shuffling, coin flips, etc. are deterministic
- Network Disruption: Test recovery after connection issues
- Latency Compensation: Test playability under various latency conditions
Testing RNG with Replicon
Our new approach using bevy_replicon for RNG state management requires specialized testing:
- Replicon RNG Testing Overview
- RNG State Serialization Tests
- Checkpoint Testing
- Network Disruption Recovery
- Card Shuffling Tests
Test Fixtures and Harnesses
We provide several test fixtures to simplify test implementation:
For more detailed information on specific testing areas, refer to the corresponding documentation links above.
Networking Testing Overview
This document provides a comprehensive overview of the testing approach for the Rummage MTG Commander game engine's networking functionality.
Table of Contents
Introduction
Networking code is inherently complex due to its asynchronous nature, potential for race conditions, and sensitivity to network conditions. Our testing approach is designed to address these challenges by employing a combination of unit tests, integration tests, and end-to-end tests, with a focus on simulating real-world network conditions.
Testing Principles
Our networking testing follows these core principles:
- Deterministic Tests: Tests should be repeatable and produce the same results given the same inputs
- Isolation: Individual tests should run independently without relying on state from other tests
- Real-World Conditions: Tests should simulate various network conditions including latency, packet loss, and disconnections
- Comprehensive Coverage: Tests should cover all networking components and their interactions
- Performance Validation: Tests should validate that networking performs adequately under expected loads
Testing Levels
Unit Tests
Unit tests focus on individual networking components in isolation:
#![allow(unused)] fn main() { #[test] fn test_message_serialization() { // Create a test message let message = NetworkMessage::GameAction { sequence_id: 123, player_id: 456, action: GameAction::DrawCard { player_id: 456, count: 1 }, }; // Serialize the message let serialized = bincode::serialize(&message).expect("Serialization failed"); // Deserialize the message let deserialized: NetworkMessage = bincode::deserialize(&serialized).expect("Deserialization failed"); // Verify the deserialized message matches the original assert_eq!(message, deserialized); } }
Integration Tests
Integration tests verify that multiple components work together correctly:
#![allow(unused)] fn main() { #[test] fn test_client_server_connection() { // Set up a server let mut server_app = App::new(); server_app.add_plugins(MinimalPlugins) .add_plugins(RepliconServerPlugin::default()); // Set up a client let mut client_app = App::new(); client_app.add_plugins(MinimalPlugins) .add_plugins(RepliconClientPlugin::default()); // Start the server server_app.world.resource_mut::<RepliconServer>() .start_endpoint(ServerEndpoint::new(8080)); // Connect the client client_app.world.resource_mut::<RepliconClient>() .connect_endpoint(ClientEndpoint::new("127.0.0.1", 8080)); // Run updates to establish connection for _ in 0..10 { server_app.update(); client_app.update(); } // Verify the client is connected let client = client_app.world.resource::<RepliconClient>(); assert!(client.is_connected()); } }
End-to-End Tests
End-to-end tests verify complete game scenarios:
#![allow(unused)] fn main() { #[test] fn test_multiplayer_game_flow() { // Set up a server with a game let mut server_app = setup_server_with_game(); // Set up clients for multiple players let mut client_apps = vec![ setup_client_app(0), setup_client_app(1), setup_client_app(2), setup_client_app(3), ]; // Connect all clients connect_all_clients(&mut server_app, &mut client_apps); // Run a full game turn cycle run_game_turn_cycle(&mut server_app, &mut client_apps); // Verify game state is consistent across all clients verify_consistent_game_state(&server_app, &client_apps); } }
Test Fixtures
Basic Network Test Fixture
#![allow(unused)] fn main() { /// Sets up a standard network test environment with server and clients pub fn setup_network_test(app: &mut App, is_server: bool, is_client: bool) { // Add required plugins app.add_plugins(MinimalPlugins); // Add either server or client plugins if is_server { app.add_plugins(RepliconServerPlugin::default()); } if is_client { app.add_plugins(RepliconClientPlugin::default()); } // Add networking resources app.init_resource::<NetworkConfig>() .init_resource::<ConnectionStatus>(); // Add core networking systems app.add_systems(Update, network_connection_status_update); } }
Game State Test Fixture
#![allow(unused)] fn main() { /// Sets up a test environment with a standard game state pub fn setup_test_game_state(app: &mut App) { // Add game state app.init_resource::<GameState>(); // Set up players let player_entities = spawn_test_players(app); // Set up initial game board setup_test_board_state(app, &player_entities); // Initialize game systems app.add_systems(Update, ( update_game_state, process_game_actions, sync_game_state, )); } }
Network Simulation
To test under various network conditions, we use a network condition simulator:
#![allow(unused)] fn main() { /// Simulates network conditions for testing pub struct NetworkConditionSimulator { /// Simulated latency in milliseconds pub latency: u32, /// Packet loss percentage (0-100) pub packet_loss: u8, /// Jitter in milliseconds pub jitter: u32, /// Bandwidth cap in KB/s pub bandwidth: u32, } impl NetworkConditionSimulator { /// Applies network conditions to a packet pub fn process_packet(&self, packet: &mut Packet) { // Apply packet loss if rand::random::<u8>() < self.packet_loss { packet.dropped = true; return; } // Apply latency with jitter let jitter_amount = if self.jitter > 0 { rand::thread_rng().gen_range(0..self.jitter) } else { 0 }; packet.delay = Duration::from_millis((self.latency + jitter_amount) as u64); // Apply bandwidth limitation if self.bandwidth > 0 { packet.throttled = packet.size > self.bandwidth; } } } }
Automation Approach
Our testing automation strategy focuses on:
- Continuous Integration: All networking tests run on every PR and merge to main
- Matrix Testing: Tests run against multiple configurations (OS, Bevy version, etc.)
- Performance Benchmarks: Regular testing of networking performance metrics
- Stress Testing: Load tests to verify behavior under heavy usage
- Long-running Tests: Tests that run for extended periods to catch time-dependent issues
Key Test Scenarios
The following critical scenarios must pass for all networking changes:
- Connection Handling: Establishing connections, handling disconnections, and reconnections
- State Synchronization: Ensuring all clients see the same game state
- Latency Compensation: Verifying the game remains playable under various latency conditions
- Error Recovery: Testing recovery from network errors and disruptions
- Security: Validating that security measures work as expected
For more detailed testing information, see the RNG Synchronization Tests and Replicon RNG Tests documents.
Testing RNG Synchronization in Networked Gameplay
This document outlines testing strategies for ensuring deterministic and synchronized random number generation across network boundaries in our MTG Commander game engine.
Table of Contents
- Testing Goals
- Test Categories
- Test Fixtures
- Automated Tests
- Manual Testing
- Performance Considerations
Testing Goals
Testing RNG synchronization focuses on these key goals:
- Determinism: Verify that identical RNG seeds produce identical random sequences on all clients
- State Preservation: Ensure RNG state is properly serialized, transmitted, and restored
- Resilience: Test recovery from network disruptions or client reconnections
- Sequence Integrity: Confirm that game actions using randomness always produce the same results
Test Categories
Basic Determinism Tests
These tests verify that the underlying RNG components work deterministically:
#![allow(unused)] fn main() { #[test] fn test_rng_determinism() { // Create two RNGs with the same seed let seed = 12345u64; let mut rng1 = WyRand::seed_from_u64(seed); let mut rng2 = WyRand::seed_from_u64(seed); // Generate sequences from both RNGs let sequence1: Vec<u32> = (0..100).map(|_| rng1.next_u32()).collect(); let sequence2: Vec<u32> = (0..100).map(|_| rng2.next_u32()).collect(); // Verify sequences are identical assert_eq!(sequence1, sequence2); } }
Serialization Tests
These tests verify that RNG state can be properly serialized and deserialized:
#![allow(unused)] fn main() { #[test] fn test_rng_serialization() { // Create an RNG and use it to generate some values let mut original_rng = GlobalEntropy::<WyRand>::from_entropy(); let original_values: Vec<u32> = (0..10).map(|_| original_rng.next_u32()).collect(); // Serialize the RNG state let serialized_state = original_rng.try_serialize_state().expect("Failed to serialize RNG state"); // Create a new RNG and deserialize the state into it let mut new_rng = GlobalEntropy::<WyRand>::from_entropy(); new_rng.deserialize_state(&serialized_state).expect("Failed to deserialize RNG state"); // Generate the same number of values from the new RNG let new_values: Vec<u32> = (0..10).map(|_| new_rng.next_u32()).collect(); // The values should be the same, since the states were synchronized assert_eq!(original_values, new_values); } }
Network Transmission Tests
These tests verify that RNG state can be properly transmitted across the network:
#![allow(unused)] fn main() { #[test] fn test_rng_network_transmission() { // Setup server and client apps let mut server_app = App::new(); let mut client_app = App::new(); // Configure apps for network testing setup_network_test(&mut server_app, true, false); setup_network_test(&mut client_app, false, true); // Run updates to establish connection for _ in 0..5 { server_app.update(); client_app.update(); } // Generate some random values on the server let server_values = { let mut rng = server_app.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..10).map(|_| rng.next_u32()).collect::<Vec<_>>() }; // Serialize and send RNG state from server to client let serialized_state = { let rng = server_app.world.resource::<GlobalEntropy<WyRand>>(); rng.try_serialize_state().expect("Failed to serialize RNG state") }; // Simulate network transmission let rng_message = RngStateMessage { state: serialized_state, timestamp: server_app.world.resource::<Time>().elapsed_seconds(), }; // Apply the RNG state to the client { let mut client_rng = client_app.world.resource_mut::<GlobalEntropy<WyRand>>(); client_rng.deserialize_state(&rng_message.state).expect("Failed to deserialize RNG state"); } // Generate the same number of values on the client let client_values = { let mut rng = client_app.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..10).map(|_| rng.next_u32()).collect::<Vec<_>>() }; // Verify the values match assert_eq!(server_values, client_values); } }
Player-Specific RNG Tests
These tests verify that player-specific RNGs maintain determinism:
#![allow(unused)] fn main() { #[test] fn test_player_rng_forking() { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugins(EntropyPlugin::<WyRand>::default()) .init_resource::<PlayerRegistry>(); // Add test systems app.add_systems(Startup, setup_test_players) .add_systems(Update, fork_player_rngs); // Run the app to set up players and fork RNGs app.update(); // Get player entities let player_registry = app.world.resource::<PlayerRegistry>(); let player1 = player_registry.get_player(1); let player2 = player_registry.get_player(2); // Generate random sequences for each player let player1_values = generate_random_sequence(&mut app.world, player1); let player2_values = generate_random_sequence(&mut app.world, player2); // Values should be different because they were forked from the global RNG assert_ne!(player1_values, player2_values); // Save player RNG states save_player_rng_states(&mut app.world); // Create a new app and restore the states let mut new_app = App::new(); // Configure new app // ... // Restore the player RNG states restore_player_rng_states(&mut new_app.world); // Generate new sequences let new_player1_values = generate_random_sequence(&mut new_app.world, player1); let new_player2_values = generate_random_sequence(&mut new_app.world, player2); // New sequences should match the original ones assert_eq!(player1_values, new_player1_values); assert_eq!(player2_values, new_player2_values); } }
Game Action Tests
These tests verify that game actions involving randomness produce consistent results:
#![allow(unused)] fn main() { #[test] fn test_shuffle_library_determinism() { // Setup test environment let mut app = setup_multiplayer_test_app(); // Create a deck with known order let cards = (1..53).collect::<Vec<_>>(); // Setup player and library let player_entity = spawn_test_player(&mut app.world); let library_entity = spawn_test_library(&mut app.world, player_entity, cards.clone()); // Seed the player's RNG seed_player_rng(&mut app.world, player_entity, 12345u64); // First shuffle app.world.send_event(ShuffleLibraryEvent { library_entity }); app.update(); // Get shuffled order let first_shuffle = get_library_order(&app.world, library_entity); // Reset library and RNG to original state reset_library(&mut app.world, library_entity, cards.clone()); seed_player_rng(&mut app.world, player_entity, 12345u64); // Second shuffle app.world.send_event(ShuffleLibraryEvent { library_entity }); app.update(); // Get shuffled order let second_shuffle = get_library_order(&app.world, library_entity); // Both shuffles should result in the same order assert_eq!(first_shuffle, second_shuffle); } }
Test Fixtures
Common test fixtures for RNG testing:
#![allow(unused)] fn main() { /// Sets up a test environment with multiple clients pub fn setup_multiplayer_rng_test() -> TestHarness { let mut harness = TestHarness::new(); // Setup server harness.create_server_app(); // Setup multiple clients for i in 0..4 { harness.create_client_app(i); } // Initialize RNG with a fixed seed harness.seed_global_rng(12345u64); // Connect clients to server harness.connect_all_clients(); harness } /// Executes a randomized game action on all clients and verifies consistency pub fn verify_random_action_consistency(harness: &mut TestHarness, action: RandomizedAction) { // Execute action on server harness.execute_on_server(action.clone()); // Synchronize RNG state to clients harness.sync_rng_state(); // Execute same action on all clients let results = harness.execute_on_all_clients(action); // All results should be identical let first_result = &results[0]; for result in &results[1..] { assert_eq!(first_result, result); } } }
Automated Tests
Integration with CI Pipeline
Include these RNG synchronization tests in the CI pipeline:
# .github/workflows/rng-tests.yml
name: RNG Synchronization Tests
on:
push:
branches: [ main ]
paths:
- 'src/networking/rng/**'
- 'src/game_engine/actions/**'
pull_request:
branches: [ main ]
paths:
- 'src/networking/rng/**'
- 'src/game_engine/actions/**'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
- name: Run RNG tests
run: cargo test --package rummage --lib networking::rng
Test Coverage Requirements
Aim for these coverage targets for RNG synchronization code:
- Line Coverage: At least 90%
- Branch Coverage: At least 85%
- Function Coverage: 100%
Manual Testing
Some aspects of RNG synchronization should be manually tested:
- Disconnection Recovery: Test that clients reconnecting receive correct RNG state
- High Latency Scenarios: Test with artificially high network latency
- Packet Loss: Test with simulated packet loss to verify recovery
- Cross-Platform Consistency: Verify RNG consistency between different operating systems
Performance Considerations
When testing RNG synchronization, monitor these performance metrics:
- Serialization Size: RNG state should be compact
- Synchronization Frequency: Balance consistency vs. network overhead
- CPU Overhead: Monitor CPU usage during RNG-heavy operations
- Memory Usage: Track memory usage when many player-specific RNGs are active
Documentation and Logging
Implement thorough logging for RNG synchronization to aid in debugging:
#![allow(unused)] fn main() { pub fn log_rng_sync( rng_state: Res<RngStateTracker>, client_id: Option<Res<ClientId>>, ) { if let Some(client_id) = client_id { info!( "RNG sync: Client {} received state of size {} bytes (timestamp: {})", client_id.0, rng_state.global_state.len(), rng_state.last_sync ); } else { info!( "RNG sync: Server updated state of size {} bytes (timestamp: {})", rng_state.global_state.len(), rng_state.last_sync ); } } }
Advanced Networking Testing Strategies for MTG Commander
This document expands upon our core testing strategies with advanced test scenarios and methodologies specifically designed for the complex requirements of an online MTG Commander implementation.
Table of Contents
- Commander-Specific Testing
- Long-Running Game Tests
- Concurrency and Race Condition Testing
- Snapshot and Replay Testing
- Fault Injection Testing
- Load and Stress Testing
- Cross-Platform Testing
- Automated Test Generation
- Property-Based Testing
- Test Coverage Analysis
Commander-Specific Testing
Commander as a format has unique rules and complex card interactions that require specialized testing.
Multiplayer Politics Testing
Test how the networking handles multi-player political interactions:
#![allow(unused)] fn main() { #[test] fn test_multiplayer_politics() { let mut app = setup_multiplayer_test_app(4); // 4 players // Simulate player 1 offering a deal to player 2 app.world.resource_mut::<TestController>() .execute_action(TestAction::ProposeDeal { proposer: 1, target: 2, proposal: "I won't attack you this turn if you don't attack me next turn".to_string() }); app.update_n_times(5); // Allow time for messages to propagate // Verify deal is visible to both players but hidden from others let client1 = get_client_view(&app, 1); let client2 = get_client_view(&app, 2); let client3 = get_client_view(&app, 3); let client4 = get_client_view(&app, 4); assert!(client1.can_see_proposal(proposal_id)); assert!(client2.can_see_proposal(proposal_id)); assert!(!client3.can_see_proposal(proposal_id)); assert!(!client4.can_see_proposal(proposal_id)); // Player 2 accepts deal app.world.resource_mut::<TestController>() .execute_action(TestAction::RespondToDeal { player: 2, proposal_id, response: ProposalResponse::Accept }); app.update_n_times(5); // Verify deal acceptance is recorded and visible assert!(client1.proposals.contains_accepted(proposal_id)); assert!(client2.proposals.contains_accepted(proposal_id)); } }
Commander Damage Testing
Verify correct tracking and synchronization of commander damage:
#![allow(unused)] fn main() { #[test] fn test_commander_damage_tracking() { let mut app = setup_multiplayer_test_app(4); // Setup commanders for all players for player_id in 1..=4 { app.world.resource_mut::<TestController>() .setup_commander(player_id, commander_card_ids[player_id-1]); } // Player 1's commander deals damage to player 2 app.world.resource_mut::<TestController>() .execute_action(TestAction::AttackWithCommander { attacker: 1, defender: 2, damage: 5 }); app.update_n_times(10); // Verify commander damage is tracked and synchronized for player_id in 1..=4 { let client = get_client_view(&app, player_id); // All players should see same commander damage value assert_eq!( client.commander_damage_received[2][1], 5, "Player {} sees incorrect commander damage", player_id ); } // Second attack with commander app.world.resource_mut::<TestController>() .execute_action(TestAction::AttackWithCommander { attacker: 1, defender: 2, damage: 6 }); app.update_n_times(10); // Check for player death from 21+ commander damage for player_id in 1..=4 { let client = get_client_view(&app, player_id); // Commander damage should be cumulative assert_eq!(client.commander_damage_received[2][1], 11); // Player 2 is still alive (under 21 damage) assert!(client.players[2].is_alive); } // Final attack for lethal commander damage app.world.resource_mut::<TestController>() .execute_action(TestAction::AttackWithCommander { attacker: 1, defender: 2, damage: 10 }); app.update_n_times(10); // Verify player death and synchronization for player_id in 1..=4 { let client = get_client_view(&app, player_id); // Player 2 should be dead from commander damage assert_eq!(client.commander_damage_received[2][1], 21); assert!(!client.players[2].is_alive); assert_eq!( client.players[2].death_reason, DeathReason::CommanderDamage(1) ); } } }
Long-Running Game Tests
MTG Commander games can be lengthy. We need to test stability and synchronization over extended play sessions.
#![allow(unused)] fn main() { #[test] fn test_long_running_game() { let mut app = setup_multiplayer_test_app(4); // Configure for extended test app.insert_resource(LongGameSimulation { turns_to_simulate: 20, actions_per_turn: 30, complexity_factor: 0.8, // Higher values = more complex actions }); // Run the long game simulation app.add_systems(Update, long_game_simulation); // Run enough updates for the full simulation // (20 turns * 30 actions * 5 updates per action) app.update_n_times(20 * 30 * 5); // Assess results let metrics = app.world.resource::<GameMetrics>(); // Check key reliability metrics assert!(metrics.desync_events == 0, "Game had state desynchronization events"); assert!(metrics.error_count < 5, "Too many errors during extended play"); assert!(metrics.network_bandwidth_average < MAX_BANDWIDTH_TARGET); // Verify final game state consistency verify_game_state_consistency(&app); } }
Concurrency and Race Condition Testing
Test for race conditions and concurrency issues that might occur when multiple network events arrive simultaneously:
#![allow(unused)] fn main() { #[test] fn test_simultaneous_actions() { let mut app = setup_multiplayer_test_app(4); // Create a scenario where multiple clients try to act at the exact same time let test_controller = app.world.resource_mut::<TestController>(); // Configure test to deliver these actions "simultaneously" to the server test_controller.queue_simultaneous_actions(vec![ (1, TestAction::CastSpell { card_id: 101, targets: vec![201] }), (2, TestAction::ActivateAbility { card_id: 202, ability_id: 1 }), (3, TestAction::PlayLand { card_id: 303 }), ]); // Process the simultaneous actions app.update(); // Verify the server handled them in a deterministic order // and all clients ended up with the same state let server_state = extract_server_game_state(&app); for client_id in 1..=4 { let client_state = extract_client_game_state(&app, client_id); assert_eq!( server_state.action_history, client_state.action_history, "Client {} has different action order from server", client_id ); } } }
Snapshot and Replay Testing
Implement snapshot testing to capture and replay complex game states to ensure deterministic behavior:
#![allow(unused)] fn main() { #[test] fn test_game_state_snapshot_restore() { let mut app = setup_multiplayer_test_app(4); // Play several turns to reach a complex game state simulate_game_turns(&mut app, 5); // Take a snapshot of the current game state let snapshot = create_game_snapshot(&app); // Save snapshot to disk for future tests save_snapshot(&snapshot, "complex_board_state.snapshot"); // Create a fresh app and restore the snapshot let mut restored_app = setup_multiplayer_test_app(4); restore_game_snapshot(&mut restored_app, &snapshot); // Verify restored state matches original let original_state = extract_game_state(&app); let restored_state = extract_game_state(&restored_app); assert_eq!(original_state, restored_state, "Restored state does not match original"); // Execute identical actions on both instances let test_actions = generate_test_actions(); for action in &test_actions { execute_action(&mut app, action); execute_action(&mut restored_app, action); } // Verify both instances end in identical states let final_original_state = extract_game_state(&app); let final_restored_state = extract_game_state(&restored_app); assert_eq!( final_original_state, final_restored_state, "Divergent states after identical action sequences" ); } }
Fault Injection Testing
Systematically introduce faults to verify the system's resilience:
#![allow(unused)] fn main() { #[test] fn test_client_disconnect_reconnect() { let mut app = setup_multiplayer_test_app(4); // Play several turns simulate_game_turns(&mut app, 3); // Force disconnect client 2 app.world.resource_mut::<NetworkController>() .disconnect_client(2); // Continue game for a turn simulate_game_turns(&mut app, 1); // Snapshot game state before reconnection let state_before_reconnect = extract_game_state(&app); // Reconnect client 2 app.world.resource_mut::<NetworkController>() .reconnect_client(2); // Allow time for state synchronization app.update_n_times(20); // Verify reconnected client has correct state let client2_state = extract_client_game_state(&app, 2); let server_state = extract_server_game_state(&app); assert_eq!( server_state.public_state, client2_state.public_state, "Reconnected client failed to synchronize state" ); // Continue play and verify client 2 can take actions app.world.resource_mut::<TestController>() .execute_action(TestAction::PlayCard { player_id: 2, card_id: 123 }); app.update_n_times(5); // Verify action was processed let updated_state = extract_game_state(&app); assert!(updated_state.contains_played_card(2, 123)); } }
Load and Stress Testing
Test how the system performs under high load:
#![allow(unused)] fn main() { #[test] fn test_high_action_throughput() { let mut app = setup_multiplayer_test_app(4); // Configure test with high action frequency app.insert_resource(ActionThroughputTest { actions_per_second: 50, test_duration_seconds: 30, }); // Run stress test app.add_systems(Update, high_throughput_test); app.update_n_times(30 * 60); // 30 seconds at 60 fps // Collect performance metrics let metrics = app.world.resource::<PerformanceMetrics>(); // Verify system maintained performance under load assert!(metrics.action_processing_success_rate > 0.95); // >95% successful assert!(metrics.average_frame_time < 16.67); // Maintain 60fps (16.67ms) assert!(metrics.network_bandwidth_max < MAX_BANDWIDTH_LIMIT); // Verify game state remained consistent despite high throughput verify_game_state_consistency(&app); } }
Cross-Platform Testing
Test networking across different platforms and configurations:
#![allow(unused)] fn main() { // This would be implemented in CI pipeline to test different combinations fn test_cross_platform() { // Test matrix for different platforms/configurations let platforms = ["Windows", "MacOS", "Linux"]; let network_conditions = [ NetworkCondition::Ideal, NetworkCondition::Home, NetworkCondition::Mobile, ]; for server_platform in &platforms { for client_platform in &platforms { for &network_condition in &network_conditions { // Create test configuration let test_config = CrossPlatformTest { server_platform: server_platform.to_string(), client_platform: client_platform.to_string(), network_condition, }; // This would trigger tests on CI infrastructure run_cross_platform_test(test_config); } } } } }
Automated Test Generation
Implement systems to automatically generate test scenarios:
#![allow(unused)] fn main() { #[test] fn test_generated_scenarios() { let mut app = setup_multiplayer_test_app(4); // Generate random but valid test scenarios let scenario_generator = TestScenarioGenerator::new() .with_seed(12345) // For reproducibility .with_complexity(TestComplexity::High) .with_game_progress(GameProgress::MidGame) .with_focus(TestFocus::ComplexBoardStates); // Generate 10 different test scenarios for _ in 0..10 { let scenario = scenario_generator.generate(); // Set up the test scenario setup_test_scenario(&mut app, &scenario); // Run the test actions for action in scenario.actions { app.world.resource_mut::<TestController>().execute_action(action); app.update_n_times(5); } // Verify game state is consistent and valid verify_game_state_consistency(&app); verify_game_rules_not_violated(&app); // Reset for next scenario app.world.resource_mut::<TestController>().reset_game(); } } }
Property-Based Testing
Use property-based testing to verify game invariants hold under various conditions:
#![allow(unused)] fn main() { #[test] fn test_game_invariants() { // Setup property testing proptest::proptest!(|( num_players in 2..=4usize, game_turns in 1..20usize, actions_per_turn in 1..30usize, network_latency_ms in 0..500u64, packet_loss_percent in 0.0..15.0f32, )| { // Create test app with the specified conditions let mut app = setup_multiplayer_test_app(num_players); // Configure network conditions app.insert_resource(NetworkSimulation { latency: Some(network_latency_ms), packet_loss: Some(packet_loss_percent / 100.0), jitter: Some(network_latency_ms / 5), }); // Run simulation simulate_game_with_params(&mut app, game_turns, actions_per_turn); // Check invariants that should always hold true let game_state = extract_game_state(&app); // Invariant 1: All players have exactly 1 commander (if alive) for player_id in 1..=num_players { let player = &game_state.players[player_id-1]; if player.is_alive { assert_eq!(player.commanders.len(), 1); } } // Invariant 2: Total cards in game remains constant assert_eq!( game_state.total_cards_in_game, STARTING_CARD_COUNT * num_players ); // Invariant 3: No player has negative life for player in &game_state.players { assert!(player.life >= 0); } // Invariant 4: All clients have consistent public information verify_public_state_consistency(&app); }); } }
Test Coverage Analysis
Implement analysis tools to track test coverage specifically for networking and multiplayer aspects:
#![allow(unused)] fn main() { #[test] fn generate_test_coverage_report() { // Run all networking tests with coverage tracking let results = run_tests_with_coverage("networking::"); // Analyze coverage results let coverage = NetworkingCoverageAnalysis::from_results(&results); // Generate coverage report let report = coverage.generate_report(); // Output report to file std::fs::write("networking_coverage.html", report).unwrap(); // Verify minimum coverage thresholds assert!(coverage.message_types_covered > 0.95); // >95% of message types tested assert!(coverage.error_handlers_covered > 0.9); // >90% of error handlers tested assert!(coverage.synchronization_paths_covered > 0.9); // >90% of sync paths tested // Print coverage summary println!("Network message coverage: {:.1}%", coverage.message_types_covered * 100.0); println!("Error handler coverage: {:.1}%", coverage.error_handlers_covered * 100.0); println!("Sync path coverage: {:.1}%", coverage.synchronization_paths_covered * 100.0); } }
By implementing these advanced testing strategies along with our core testing approaches, we can ensure our MTG Commander online implementation is robust, performant, and provides a faithful and enjoyable multiplayer experience across various network conditions and edge cases.
Simulated Network Conditions
Testing Replicon Integration with RNG State Management
This document outlines specific test cases and methodologies for verifying the correct integration of bevy_replicon with our RNG state management system.
Table of Contents
- Introduction
- Test Environment Setup
- Unit Tests
- Integration Tests
- End-to-End Tests
- Performance Tests
- Debugging Failures
- Snapshot System Integration
Introduction
Testing the integration of bevy_replicon with RNG state management presents unique challenges:
- Network conditions are variable and unpredictable
- Randomized operations must be deterministic across network boundaries
- Rollbacks must preserve the exact RNG state
- Any deviations in RNG state can lead to unpredictable game outcomes
Our testing approach focuses on verifying determinism under various network conditions and ensuring proper recovery after disruptions.
Snapshot System Integration
For general information about testing the snapshot system, please refer to the centralized Snapshot System documentation:
The tests in this document specifically focus on the integration between the snapshot system, bevy_replicon, and RNG state management. When running these tests, it's important to also run the general snapshot system tests to ensure complete coverage.
Test Environment Setup
Local Network Testing Harness
#![allow(unused)] fn main() { /// Struct for testing replicon and RNG integration pub struct RepliconRngTestHarness { /// Server application pub server_app: App, /// Client applications (can have multiple) pub client_apps: Vec<App>, /// Network conditions simulator pub network_conditions: NetworkConditionSimulator, /// Test seed for deterministic behavior pub test_seed: u64, } impl RepliconRngTestHarness { /// Create a new test harness with the specified number of clients pub fn new(num_clients: usize) -> Self { let mut server_app = App::new(); let mut client_apps = Vec::with_capacity(num_clients); // Setup server server_app.add_plugins(MinimalPlugins) .add_plugins(DefaultRngPlugin) .add_plugins(RepliconServerPlugin::default()) .add_plugin(RepliconRngRollbackPlugin) .add_plugin(SnapshotPlugin); // Add the snapshot plugin // Setup RNG with specific seed for repeatability let test_seed = 12345u64; server_app.world.resource_mut::<GlobalEntropy<WyRand>>().seed_from_u64(test_seed); // Setup clients for _ in 0..num_clients { let mut client_app = App::new(); client_app.add_plugins(MinimalPlugins) .add_plugins(DefaultRngPlugin) .add_plugins(RepliconClientPlugin::default()) .add_plugin(RepliconRngRollbackPlugin) .add_plugin(SnapshotPlugin); // Add the snapshot plugin // Each client gets the same seed client_app.world.resource_mut::<GlobalEntropy<WyRand>>().seed_from_u64(test_seed); client_apps.push(client_app); } Self { server_app, client_apps, network_conditions: NetworkConditionSimulator::default(), test_seed, } } /// Connect all clients to the server pub fn connect_all_clients(&mut self) { // Setup server to listen let server_port = 8080; self.server_app.world.resource_mut::<RepliconServer>() .start_endpoint(ServerEndpoint::new(server_port)); // Connect clients for (i, client_app) in self.client_apps.iter_mut().enumerate() { client_app.world.resource_mut::<RepliconClient>() .connect_endpoint(ClientEndpoint::new("127.0.0.1", server_port)); } // Update a few times to establish connections for _ in 0..10 { self.server_app.update(); for client_app in &mut self.client_apps { client_app.update(); } } } /// Simulate network disruption for a specific client pub fn simulate_disruption(&mut self, client_idx: usize, duration_ms: u64) { self.network_conditions.disconnect_client(client_idx, duration_ms); } /// Run a test with network conditions pub fn run_with_conditions<F>(&mut self, update_count: usize, test_fn: F) where F: Fn(&mut Self, usize) { for i in 0..update_count { // Apply network conditions self.network_conditions.update(&mut self.client_apps); // Run server update self.server_app.update(); // Run client updates for client_app in &mut self.client_apps { client_app.update(); } // Call test function test_fn(self, i); } } /// Create a snapshot on the server pub fn create_server_snapshot(&mut self) -> Uuid { // Create a snapshot self.server_app.world.send_event(SnapshotEvent::Take); self.server_app.update(); // Return the snapshot ID self.server_app.world.resource::<SnapshotRegistry>() .most_recent() .unwrap() .id } /// Apply a snapshot on the server pub fn apply_server_snapshot(&mut self, snapshot_id: Uuid) { self.server_app.world.send_event(SnapshotEvent::Apply(snapshot_id)); self.server_app.update(); } } /// Simulates different network conditions pub struct NetworkConditionSimulator { /// Client disconnection timers pub disconnection_timers: HashMap<usize, u64>, /// Packet loss percentages pub packet_loss_rates: HashMap<usize, f32>, /// Latency values pub latencies: HashMap<usize, u64>, } impl NetworkConditionSimulator { /// Disconnect a client for a duration pub fn disconnect_client(&mut self, client_idx: usize, duration_ms: u64) { self.disconnection_timers.insert(client_idx, duration_ms); } /// Apply network conditions to clients pub fn update(&mut self, client_apps: &mut [App]) { // Update disconnection timers and reconnect if needed let mut reconnect = Vec::new(); for (client_idx, timer) in &mut self.disconnection_timers { if *timer <= 16 { reconnect.push(*client_idx); } else { *timer -= 16; // Assuming 60 FPS } } for client_idx in reconnect { self.disconnection_timers.remove(&client_idx); } } } }
Unit Tests
Testing RNG State Serialization and Deserialization
#![allow(unused)] fn main() { #[test] fn test_rng_state_serialization() { // Create a test app let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugins(DefaultRngPlugin) .init_resource::<RngReplicationState>(); // Setup global RNG with specific seed let test_seed = 12345u64; app.world.resource_mut::<GlobalEntropy<WyRand>>().seed_from_u64(test_seed); // Generate some random values and store them let original_values: Vec<u32> = { let mut rng = app.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..10).map(|_| rng.gen::<u32>()).collect() }; // Capture the RNG state let mut rng_state = app.world.resource_mut::<RngReplicationState>(); let global_rng = app.world.resource::<GlobalEntropy<WyRand>>(); rng_state.global_state = global_rng.try_serialize_state().unwrap(); // Create a new app with fresh RNG let mut new_app = App::new(); new_app.add_plugins(MinimalPlugins) .add_plugins(DefaultRngPlugin); // Apply the saved state let mut new_global_rng = new_app.world.resource_mut::<GlobalEntropy<WyRand>>(); new_global_rng.deserialize_state(&rng_state.global_state).unwrap(); // Generate values from the new RNG let new_values: Vec<u32> = { let mut rng = new_app.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..10).map(|_| rng.gen::<u32>()).collect() }; // Values should be different from the original sequence // because we captured the state after generating the original values assert_ne!(original_values, new_values); // Reset both RNGs to the same seed and generate sequences app.world.resource_mut::<GlobalEntropy<WyRand>>().seed_from_u64(test_seed); new_app.world.resource_mut::<GlobalEntropy<WyRand>>().seed_from_u64(test_seed); let reset_values1: Vec<u32> = { let mut rng = app.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..10).map(|_| rng.gen::<u32>()).collect() }; let reset_values2: Vec<u32> = { let mut rng = new_app.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..10).map(|_| rng.gen::<u32>()).collect() }; // Values should now be identical assert_eq!(reset_values1, reset_values2); } }
Testing Snapshot System with RNG State
#![allow(unused)] fn main() { #[test] fn test_snapshot_preserves_rng_state() { // Create a test app with both the RNG and snapshot plugins let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugins(DefaultRngPlugin) .add_plugin(SnapshotPlugin) .add_plugin(RepliconRngRollbackPlugin) .init_resource::<SequenceTracker>(); // Seed RNG let test_seed = 12345u64; app.world.resource_mut::<GlobalEntropy<WyRand>>().seed_from_u64(test_seed); // Create an entity with a marker component app.world.spawn(( Snapshotable, RandomizedBehavior::default(), Transform::default(), )); // Generate some initial values and mark the entity as using them let initial_values: Vec<u32> = { let mut rng = app.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..5).map(|_| rng.gen::<u32>()).collect() }; // Update sequence in the entity let mut entities = app.world.query::<&mut RandomizedBehavior>(); for mut behavior in entities.iter_mut(&mut app.world) { behavior.last_rng_sequence = 1; } // Create a snapshot app.world.send_event(SnapshotEvent::Take); app.update(); // Get the snapshot ID let snapshot_id = app.world.resource::<SnapshotRegistry>() .most_recent() .unwrap() .id; // Generate more random values, changing the RNG state let _more_values: Vec<u32> = { let mut rng = app.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..10).map(|_| rng.gen::<u32>()).collect() }; // Apply the snapshot to restore the game state including RNG app.world.send_event(SnapshotEvent::Apply(snapshot_id)); app.update(); // Generate new values from the restored RNG state let restored_values: Vec<u32> = { let mut rng = app.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..5).map(|_| rng.gen::<u32>()).collect() }; // These values should be different from the initial values // since the RNG state has advanced, but they should be deterministic assert_ne!(initial_values, restored_values); // Create a new app and repeat the process to verify determinism let mut app2 = App::new(); app2.add_plugins(MinimalPlugins) .add_plugins(DefaultRngPlugin) .add_plugin(SnapshotPlugin) .add_plugin(RepliconRngRollbackPlugin) .init_resource::<SequenceTracker>(); // Seed RNG the same way app2.world.resource_mut::<GlobalEntropy<WyRand>>().seed_from_u64(test_seed); // Create the same entity app2.world.spawn(( Snapshotable, RandomizedBehavior::default(), Transform::default(), )); // Generate the initial values exactly as before let _initial_values2: Vec<u32> = { let mut rng = app2.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..5).map(|_| rng.gen::<u32>()).collect() }; // Update sequence in the entity let mut entities = app2.world.query::<&mut RandomizedBehavior>(); for mut behavior in entities.iter_mut(&mut app2.world) { behavior.last_rng_sequence = 1; } // Create a snapshot app2.world.send_event(SnapshotEvent::Take); app2.update(); // Get the snapshot ID let snapshot_id2 = app2.world.resource::<SnapshotRegistry>() .most_recent() .unwrap() .id; // Generate more random values, changing the RNG state let _more_values2: Vec<u32> = { let mut rng = app2.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..10).map(|_| rng.gen::<u32>()).collect() }; // Apply the snapshot to restore the game state including RNG app2.world.send_event(SnapshotEvent::Apply(snapshot_id2)); app2.update(); // Generate new values from the restored RNG state let restored_values2: Vec<u32> = { let mut rng = app2.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..5).map(|_| rng.gen::<u32>()).collect() }; // The deterministically restored values should be identical in both runs assert_eq!(restored_values, restored_values2); } }
Testing Checkpoint Creation and Restoration
#![allow(unused)] fn main() { #[test] fn test_checkpoint_creation_and_restoration() { // Create a test app with the plugin let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugins(DefaultRngPlugin) .add_plugin(RepliconRngRollbackPlugin) .init_resource::<SequenceTracker>(); // Seed RNG let test_seed = 12345u64; app.world.resource_mut::<GlobalEntropy<WyRand>>().seed_from_u64(test_seed); // Generate some initial values let initial_values: Vec<u32> = { let mut rng = app.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..5).map(|_| rng.gen::<u32>()).collect() }; // Create a checkpoint let checkpoint_sequence = 1; let mut checkpoints = app.world.resource_mut::<RollbackCheckpoints>(); let rng_state = app.world.resource::<RngReplicationState>(); let global_rng = app.world.resource::<GlobalEntropy<WyRand>>(); let checkpoint = RollbackCheckpoint { sequence_id: checkpoint_sequence, timestamp: 0.0, global_rng_state: global_rng.try_serialize_state().unwrap(), player_rng_states: HashMap::new(), replicated_entities: Vec::new(), }; checkpoints.checkpoints.insert(checkpoint_sequence, checkpoint); // Generate more values after checkpoint let post_checkpoint_values: Vec<u32> = { let mut rng = app.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..5).map(|_| rng.gen::<u32>()).collect() }; // Restore from checkpoint let checkpoint = checkpoints.checkpoints.get(&checkpoint_sequence).unwrap(); app.world.resource_mut::<GlobalEntropy<WyRand>>() .deserialize_state(&checkpoint.global_rng_state).unwrap(); // Generate values after restoration let restored_values: Vec<u32> = { let mut rng = app.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..5).map(|_| rng.gen::<u32>()).collect() }; // The restored values should match the post-checkpoint values assert_eq!(post_checkpoint_values, restored_values); } }
Integration Tests
Testing RNG Synchronization Between Server and Client
#![allow(unused)] fn main() { #[test] fn test_server_client_rng_sync() { // Create test harness with 1 client let mut harness = RepliconRngTestHarness::new(1); harness.connect_all_clients(); // Test variables let mut server_values = Vec::new(); let mut client_values = Vec::new(); // Run with updates harness.run_with_conditions(50, |harness, i| { if i == 10 { // Record server RNG values at update 10 let mut rng = harness.server_app.world.resource_mut::<GlobalEntropy<WyRand>>(); server_values = (0..5).map(|_| rng.gen::<u32>()).collect(); } if i == 20 { // Record client RNG values at update 20 // By now, RNG state should have been synced let mut rng = harness.client_apps[0].world.resource_mut::<GlobalEntropy<WyRand>>(); client_values = (0..5).map(|_| rng.gen::<u32>()).collect(); // Server will have advanced, get fresh set of values let mut rng = harness.server_app.world.resource_mut::<GlobalEntropy<WyRand>>(); server_values = (0..5).map(|_| rng.gen::<u32>()).collect(); } }); // Client values should match server values from update 20 assert_eq!(client_values, server_values); } }
Testing Rollback Due to Network Disruption
#![allow(unused)] fn main() { #[test] fn test_rollback_after_disruption() { // Create test harness with 2 clients let mut harness = RepliconRngTestHarness::new(2); harness.connect_all_clients(); // Setup game entities // ... // Run test with network disruption let mut pre_disruption_rng_values = Vec::new(); let mut post_disruption_rng_values = Vec::new(); let mut post_rollback_rng_values = Vec::new(); harness.run_with_conditions(100, |harness, i| { if i == 20 { // Record RNG values before disruption let rng = harness.server_app.world.resource::<GlobalEntropy<WyRand>>(); pre_disruption_rng_values = generate_test_random_values(rng, 10); // Simulate network disruption for client 0 harness.simulate_disruption(0, 500); // 500ms disruption } if i == 40 { // Record RNG values after disruption let rng = harness.server_app.world.resource::<GlobalEntropy<WyRand>>(); post_disruption_rng_values = generate_test_random_values(rng, 10); } if i == 60 { // By now rollback should have happened // Record RNG values after rollback let rng = harness.server_app.world.resource::<GlobalEntropy<WyRand>>(); post_rollback_rng_values = generate_test_random_values(rng, 10); // Check that client 0 and client 1 have the same RNG state let rng0 = harness.client_apps[0].world.resource::<GlobalEntropy<WyRand>>(); let rng1 = harness.client_apps[1].world.resource::<GlobalEntropy<WyRand>>(); let client0_values = generate_test_random_values(rng0, 10); let client1_values = generate_test_random_values(rng1, 10); assert_eq!(client0_values, client1_values, "Clients should have same RNG state after rollback"); } }); // Verify behavior assert_ne!(pre_disruption_rng_values, post_disruption_rng_values, "RNG values should change during normal operation"); assert_eq!(post_rollback_rng_values, post_disruption_rng_values, "After rollback, RNG sequences should match the checkpoint state"); } /// Helper function to generate random values for testing fn generate_test_random_values(rng: &GlobalEntropy<WyRand>, count: usize) -> Vec<u32> { let mut rng_clone = rng.clone(); (0..count).map(|_| rng_clone.gen::<u32>()).collect() } }
End-to-End Tests
Testing Card Shuffling During Network Disruption
#![allow(unused)] fn main() { #[test] fn test_card_shuffle_during_disruption() { // Setup test environment with card library let mut harness = RepliconRngTestHarness::new(2); harness.connect_all_clients(); // Create players and libraries let server_player1 = setup_test_player(&mut harness.server_app.world, 1); let server_player2 = setup_test_player(&mut harness.server_app.world, 2); // Create identical card libraries let cards = (1..53).collect::<Vec<i32>>(); let server_library1 = create_test_library(&mut harness.server_app.world, server_player1, cards.clone()); let server_library2 = create_test_library(&mut harness.server_app.world, server_player2, cards.clone()); // Initialize client players and libraries // ... // Shuffle results let mut server_shuffle_result1 = Vec::new(); let mut server_shuffle_result2 = Vec::new(); let mut client1_shuffle_result = Vec::new(); let mut client2_shuffle_result = Vec::new(); // Run test with network disruption during card shuffle harness.run_with_conditions(200, |harness, i| { if i == 50 { // Player 1 shuffles their library harness.server_app.world.send_event(ShuffleLibraryEvent { library_entity: server_library1 }); } if i == 60 { // Capture shuffle result server_shuffle_result1 = get_library_order(&harness.server_app.world, server_library1); // Cause network disruption harness.simulate_disruption(0, 1000); } if i == 80 { // Player 2 shuffles during disruption harness.server_app.world.send_event(ShuffleLibraryEvent { library_entity: server_library2 }); } if i == 100 { // Capture server-side shuffle results server_shuffle_result2 = get_library_order(&harness.server_app.world, server_library2); } if i == 150 { // By now, rollback and resynchronization should have occurred // Capture client-side shuffle results client1_shuffle_result = get_client_library_order(&harness.client_apps[0].world, 1); client2_shuffle_result = get_client_library_order(&harness.client_apps[1].world, 2); } }); // Verify all libraries have the same shuffle result assert_eq!(server_shuffle_result1, client1_shuffle_result, "Client 1 should have same shuffle result as server"); assert_eq!(server_shuffle_result2, client2_shuffle_result, "Client 2 should have same shuffle result as server"); } /// Helper function to get library card order fn get_library_order(world: &World, library_entity: Entity) -> Vec<i32> { if let Some(library) = world.get::<Library>(library_entity) { library.cards.clone() } else { Vec::new() } } /// Helper function to get client-side library order fn get_client_library_order(client_world: &World, player_id: i32) -> Vec<i32> { // Find player by ID let player_entity = find_player_by_id(client_world, player_id); if player_entity.is_none() { return Vec::new(); } // Find library entity let library_entity = find_library_for_player(client_world, player_entity.unwrap()); if library_entity.is_none() { return Vec::new(); } // Get library cards get_library_order(client_world, library_entity.unwrap()) } }
Performance Tests
Testing RNG State Replication Bandwidth
#![allow(unused)] fn main() { #[test] fn test_rng_replication_bandwidth() { // Create a test harness with multiple clients let mut harness = RepliconRngTestHarness::new(4); harness.connect_all_clients(); // Setup bandwidth tracking let mut bandwidth_tracker = BandwidthTracker::new(); // Run test with bandwidth monitoring harness.run_with_conditions(100, |harness, i| { if i % 10 == 0 { // Record bandwidth usage every 10 updates let server = harness.server_app.world.resource::<RepliconServer>(); bandwidth_tracker.record_bandwidth(server.get_bandwidth_stats()); } }); // Analyze bandwidth results let results = bandwidth_tracker.analyze(); // Ensure RNG state replication is within reasonable bounds assert!(results.avg_bandwidth_per_client < 1024, "Average bandwidth should be less than 1KB per client"); // Print results println!("Bandwidth results:"); println!(" Average per client: {} bytes", results.avg_bandwidth_per_client); println!(" Peak: {} bytes", results.peak_bandwidth); println!(" Total: {} bytes", results.total_bandwidth); } /// Helper struct for tracking bandwidth usage struct BandwidthTracker { samples: Vec<BandwidthSample>, } struct BandwidthSample { timestamp: f32, bytes_sent: usize, client_count: usize, } struct BandwidthResults { avg_bandwidth_per_client: f32, peak_bandwidth: usize, total_bandwidth: usize, } impl BandwidthTracker { fn new() -> Self { Self { samples: Vec::new() } } fn record_bandwidth(&mut self, stats: BandwidthStats) { self.samples.push(BandwidthSample { timestamp: stats.timestamp, bytes_sent: stats.bytes_sent, client_count: stats.client_count, }); } fn analyze(&self) -> BandwidthResults { if self.samples.is_empty() { return BandwidthResults { avg_bandwidth_per_client: 0.0, peak_bandwidth: 0, total_bandwidth: 0, }; } let total_bytes: usize = self.samples.iter().map(|s| s.bytes_sent).sum(); let peak_bytes = self.samples.iter().map(|s| s.bytes_sent).max().unwrap_or(0); let client_samples: usize = self.samples.iter().map(|s| s.client_count).sum(); let avg_per_client = if client_samples > 0 { total_bytes as f32 / client_samples as f32 } else { 0.0 }; BandwidthResults { avg_bandwidth_per_client: avg_per_client, peak_bandwidth: peak_bytes, total_bandwidth: total_bytes, } } } /// Mock struct to represent network bandwidth statistics struct BandwidthStats { timestamp: f32, bytes_sent: usize, client_count: usize, } }
Debugging Failures
When tests fail, collect diagnostic information to aid debugging:
#![allow(unused)] fn main() { fn diagnose_rng_state_mismatch( server_rng: &GlobalEntropy<WyRand>, client_rng: &GlobalEntropy<WyRand>, ) -> String { // Serialize both RNG states let server_state = server_rng.try_serialize_state().unwrap_or_default(); let client_state = client_rng.try_serialize_state().unwrap_or_default(); // Generate test values from both let mut server_rng_clone = server_rng.clone(); let mut client_rng_clone = client_rng.clone(); let server_values: Vec<u32> = (0..5).map(|_| server_rng_clone.gen::<u32>()).collect(); let client_values: Vec<u32> = (0..5).map(|_| client_rng_clone.gen::<u32>()).collect(); let mut report = String::new(); report.push_str("RNG State Mismatch Diagnostic:\n"); report.push_str(&format!("Server state: {:?}\n", server_state)); report.push_str(&format!("Client state: {:?}\n", client_state)); report.push_str(&format!("Server values: {:?}\n", server_values)); report.push_str(&format!("Client values: {:?}\n", client_values)); report } }
These tests validate that our bevy_replicon integration with RNG state management works correctly under various conditions, ensuring deterministic behavior in our networked MTG Commander game.
Remember to run these tests:
- Regularly during development
- After any changes to networking code
- After any changes to RNG-dependent game logic
- As part of the CI/CD pipeline
Networking Integration Testing
This section covers the integration testing approach for the networking components of the MTG Commander game engine.
Overview
Integration testing for networking components ensures that different parts of the networking stack work together correctly, from low-level protocols to high-level gameplay synchronization.
Components
Strategy
Detailed strategy for integration testing of networking components, including:
- Test environment setup
- Integration test suite organization
- Mocking and simulation approaches
- Continuous integration workflow
- Regression testing methodology
- Performance benchmarking
Testing Scope
The integration testing covers:
- Client-server communication
- Peer-to-peer interactions
- Protocol compatibility
- State synchronization across clients
- Error handling and recovery
- Network condition simulation
- Cross-platform compatibility
Integration with Other Testing
This testing approach integrates with:
- Unit Testing for individual component validation
- End-to-End Testing for full system validation
- Performance Testing for network performance evaluation
- Security Testing for network security validation
Integration Testing: Networking and Game Engine
This document focuses on the critical integration testing needed between the networking layer and the core MTG Commander game engine. Ensuring these two complex systems work together seamlessly is essential for a robust online implementation.
Table of Contents
- Integration Test Goals
- Test Architecture
- State Synchronization Tests
- MTG-Specific Network Integration Tests
- End-to-End Gameplay Tests
- Test Harness Implementation
- Continuous Integration Strategy
- Automated Test Generation
Integration Test Goals
Integration testing for our networked MTG Commander implementation focuses on:
- Seamless Interaction: Verifying the networking code and game engine interact without errors
- Game State Integrity: Ensuring game state remains consistent across server and clients
- Rules Enforcement: Confirming game rules are correctly enforced in multiplayer contexts
- Performance: Measuring and validating performance under realistic gameplay conditions
- Robustness: Testing recovery from network disruptions and other failures
Test Architecture
Our integration test architecture follows a layered approach:
┌─────────────────────────────────────┐
│ End-to-End Gameplay Tests │
├─────────────────────────────────────┤
│ MTG-Specific Network Integration │
├─────────────────────────────────────┤
│ State Synchronization Tests │
├─────────────────────────────────────┤
│ Component Integration Tests │
└─────────────────────────────────────┘
Each test layer builds on the previous, starting with component integration and progressing to full gameplay scenarios.
Implementation Structure
#![allow(unused)] fn main() { // src/tests/integration/mod.rs pub mod networking_game_integration { mod component_integration; mod state_synchronization; mod mtg_specific; mod end_to_end; pub use component_integration::*; pub use state_synchronization::*; pub use mtg_specific::*; pub use end_to_end::*; } }
State Synchronization Tests
These tests verify that game state is correctly synchronized between server and clients.
Game State Replication Test
#![allow(unused)] fn main() { #[test] fn test_game_state_replication() { let mut app = setup_integration_test(4); // Configure initial game state app.world.resource_mut::<TestController>() .setup_game_state(TestGameState::MidGame); // Ensure all clients start with synchronized state app.update_n_times(10); // Execute a complex game action on the server app.world.resource_mut::<TestController>() .execute_server_action(ServerAction::ResolveComplexEffect { effect_id: 123, targets: vec![1, 2, 3], values: vec![5, 10, 15] }); // Allow time for replication app.update_n_times(10); // Verify all clients received the updated state let server_state = extract_server_game_state(&app); for client_id in 1..=4 { let client_state = extract_client_game_state(&app, client_id); // Check public state is identical assert_eq!( server_state.public_state, client_state.public_state, "Client {} has inconsistent public state", client_id ); // Check player-specific private state assert_eq!( server_state.player_states[client_id].private_view, client_state.private_state, "Client {} has inconsistent private state", client_id ); } } }
Incremental Update Test
Verify that incremental updates are correctly applied:
#![allow(unused)] fn main() { #[test] fn test_incremental_updates() { let mut app = setup_integration_test(4); // Track update messages sent let mut update_tracker = UpdateTracker::new(); app.insert_resource(update_tracker); app.add_systems(Update, track_network_updates); // Execute sequence of small game actions let actions = [ GameAction::DrawCard(1), GameAction::PlayLand(1, 101), GameAction::PassPriority(1), GameAction::PassPriority(2), GameAction::CastSpell(3, 302, vec![]), ]; for action in &actions { app.world.resource_mut::<TestController>() .execute_action(action.clone()); app.update_n_times(5); } // Retrieve update tracking data let update_tracker = app.world.resource::<UpdateTracker>(); // Verify we sent incremental updates (not full state) assert!(update_tracker.full_state_updates < actions.len()); assert!(update_tracker.incremental_updates > 0); // Verify game state is consistent verify_game_state_consistency(&app); } }
MTG-Specific Network Integration Tests
These tests focus on MTG-specific game mechanics and their network integration.
Priority Passing Test
#![allow(unused)] fn main() { #[test] fn test_networked_priority_system() { let mut app = setup_integration_test(4); // Initialize game state for testing priority setup_priority_test_state(&mut app); // Track current priority holder let mut expected_priority = 1; // Start with player 1 // Pass priority around the table for _ in 0..8 { // 2 full cycles // Verify current priority let priority_system = app.world.resource::<PrioritySystem>(); assert_eq!( priority_system.current_player, expected_priority, "Incorrect priority holder" ); // Current player passes priority app.world.resource_mut::<TestController>() .execute_action(GameAction::PassPriority(expected_priority)); app.update_n_times(5); // Update expected priority (cycle 1->2->3->4->1) expected_priority = expected_priority % 4 + 1; } // Now test a player casting a spell with priority let priority_holder = expected_priority; // Execute cast spell action app.world.resource_mut::<TestController>() .execute_action(GameAction::CastSpell( priority_holder, 123, // Card ID vec![] // No targets )); app.update_n_times(5); // Verify spell was cast and on the stack let game_state = extract_server_game_state(&app); assert!(game_state.stack.contains_spell(123)); // Verify priority passed to next player let priority_system = app.world.resource::<PrioritySystem>(); assert_eq!( priority_system.current_player, priority_holder % 4 + 1, "Priority didn't pass after spell cast" ); } }
Hidden Information Test
#![allow(unused)] fn main() { #[test] fn test_networked_hidden_information() { let mut app = setup_integration_test(4); // Setup a player with cards in hand app.world.resource_mut::<TestController>() .setup_player_hand(1, vec![101, 102, 103]); app.update_n_times(5); // Verify only player 1 can see their hand contents for client_id in 1..=4 { let client_state = extract_client_game_state(&app, client_id); if client_id == 1 { // Player 1 should see all cards in their hand assert_eq!(client_state.player_hand.len(), 3); assert!(client_state.player_hand.contains(&101)); assert!(client_state.player_hand.contains(&102)); assert!(client_state.player_hand.contains(&103)); } else { // Other players should only see card backs/count assert_eq!(client_state.opponents[0].hand_size, 3); assert!(!client_state.can_see_card(101)); assert!(!client_state.can_see_card(102)); assert!(!client_state.can_see_card(103)); } } // Test revealing a card to all players app.world.resource_mut::<TestController>() .execute_action(GameAction::RevealCard(1, 102)); app.update_n_times(5); // Verify all clients can now see the revealed card for client_id in 1..=4 { let client_state = extract_client_game_state(&app, client_id); assert!(client_state.can_see_card(102)); // Other cards still hidden from opponents if client_id != 1 { assert!(!client_state.can_see_card(101)); assert!(!client_state.can_see_card(103)); } } } }
Stack Resolution Test
#![allow(unused)] fn main() { #[test] fn test_networked_stack_resolution() { let mut app = setup_integration_test(4); // Setup initial game state with empty stack setup_stack_test_state(&mut app); // Player 1 casts a spell app.world.resource_mut::<TestController>() .execute_action(GameAction::CastSpell(1, 101, vec![])); app.update_n_times(5); // Player 2 responds with their own spell app.world.resource_mut::<TestController>() .execute_action(GameAction::CastSpell(2, 201, vec![101])); // Targeting first spell app.update_n_times(5); // Player 3 and 4 pass priority app.world.resource_mut::<TestController>() .execute_action(GameAction::PassPriority(3)); app.update_n_times(5); app.world.resource_mut::<TestController>() .execute_action(GameAction::PassPriority(4)); app.update_n_times(5); // Back to player 1, who passes app.world.resource_mut::<TestController>() .execute_action(GameAction::PassPriority(1)); app.update_n_times(5); // Player 2 passes - this should resolve their spell app.world.resource_mut::<TestController>() .execute_action(GameAction::PassPriority(2)); app.update_n_times(10); // More updates for resolution // Verify player 2's spell resolved let game_state = extract_server_game_state(&app); assert!(!game_state.stack.contains_spell(201)); // Check if the effect was applied - in this case, counter player 1's spell assert!(!game_state.stack.contains_spell(101)); // Verify all clients see the empty stack for client_id in 1..=4 { let client_state = extract_client_game_state(&app, client_id); assert_eq!(client_state.stack.spells.len(), 0); } } }
End-to-End Gameplay Tests
These tests simulate full gameplay scenarios to verify the complete integrated system.
Full Turn Cycle Test
#![allow(unused)] fn main() { #[test] fn test_complete_turn_cycle() { let mut app = setup_integration_test(4); // Setup game state with predetermined decks and hands setup_deterministic_game_start(&mut app); // Execute a full turn cycle let turn_actions = generate_full_turn_actions(1); // For player 1's turn for action in turn_actions { app.world.resource_mut::<TestController>() .execute_action(action); app.update_n_times(5); } // Verify turn passed to next player let turn_system = app.world.resource::<TurnSystem>(); assert_eq!(turn_system.active_player, 2); assert_eq!(turn_system.phase, Phase::Untap); // Verify all game state is consistent verify_game_state_consistency(&app); // Check that specific expected game actions occurred let game_state = extract_server_game_state(&app); assert!(game_state.turn_history.contains_action_by_player(1, ActionType::PlayLand)); assert!(game_state.turn_history.contains_action_by_player(1, ActionType::CastSpell)); // Verify expected cards moved zones correctly assert!(game_state.battlefield.contains_card_controlled_by( played_land_id, 1 )); assert!(game_state.graveyard.contains_card(cast_spell_id)); } }
Multiplayer Interaction Test
#![allow(unused)] fn main() { #[test] fn test_multiplayer_interaction() { let mut app = setup_integration_test(4); // Setup mid-game state with interesting board presence setup_complex_board_state(&mut app); // Execute a complex multiplayer interaction: // Player 1 attacks player 3 // Player 2 interferes with a combat trick // Player 4 counters player 2's spell let interaction_sequence = [ // Player 1 declares attack GameAction::DeclareAttackers(1, vec![(creature_id_1, 3)]), // Priority passes to player 2 who casts combat trick GameAction::CastSpell(2, combat_trick_id, vec![creature_id_1]), // Player 3 passes GameAction::PassPriority(3), // Player 4 counters the combat trick GameAction::CastSpell(4, counterspell_id, vec![combat_trick_id]), // Priority passes around GameAction::PassPriority(1), GameAction::PassPriority(2), GameAction::PassPriority(3), GameAction::PassPriority(4), // Counterspell resolves, and damages goes through ]; for action in &interaction_sequence { app.world.resource_mut::<TestController>() .execute_action(action.clone()); app.update_n_times(5); } // Allow time for full resolution app.update_n_times(20); // Verify final state reflects expected outcome let game_state = extract_server_game_state(&app); // Combat trick should be countered assert!(game_state.graveyard.contains_card(combat_trick_id)); assert!(game_state.graveyard.contains_card(counterspell_id)); // Player 3 should have taken damage let player3_life = game_state.players[2].life; assert!(player3_life < STARTING_LIFE); // Verify all clients have consistent view of the outcome verify_game_state_consistency(&app); } }
Test Harness Implementation
Here's a sketch of the integration test harness structure:
#![allow(unused)] fn main() { pub struct IntegrationTestContext { pub app: App, pub network_monitor: NetworkMonitor, pub test_actions: Vec<TestAction>, pub verification_points: Vec<VerificationPoint>, } impl IntegrationTestContext { pub fn new(num_players: usize) -> Self { let mut app = App::new(); // Add minimal plugins for testing app.add_plugins(MinimalPlugins) .add_plugins(RepliconServerPlugin::default()) .add_plugins(RepliconClientPlugin::default()); // Add MTG game systems app.add_plugins(MTGCommanderGamePlugin) .add_plugins(TestControllerPlugin); // Setup network monitoring let network_monitor = NetworkMonitor::new(); app.insert_resource(network_monitor.clone()); app.add_systems(Update, monitor_network_traffic); // Initialize game with specified players app.world.resource_mut::<TestController>() .initialize_game(num_players); Self { app, network_monitor, test_actions: Vec::new(), verification_points: Vec::new(), } } pub fn queue_action(&mut self, action: TestAction) { self.test_actions.push(action); } pub fn add_verification_point(&mut self, point: VerificationPoint) { self.verification_points.push(point); } pub fn run_test(&mut self) -> TestResult { // Execute all queued actions for action in &self.test_actions { self.app.world.resource_mut::<TestController>() .execute_action(action.clone()); self.app.update_n_times(5); } // Check all verification points let mut results = Vec::new(); for point in &self.verification_points { let result = verify_point(&self.app, point); results.push(result); } TestResult { passed: results.iter().all(|r| r.passed), verification_results: results, network_stats: self.network_monitor.get_stats(), } } } }
Continuous Integration Strategy
Our CI pipeline for integration testing uses a matrix approach:
# .github/workflows/integration-tests.yml
name: Integration Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
integration-tests:
runs-on: ubuntu-latest
strategy:
matrix:
test-category: [
'component-integration',
'state-synchronization',
'mtg-specific',
'end-to-end'
]
num-players: [2, 3, 4]
steps:
- uses: actions/checkout@v2
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Build
run: cargo build --verbose
- name: Run integration tests
run: |
cargo test --verbose \
networking_game_integration::${{ matrix.test-category }}::* \
-- --test-threads=1 \
--ignored \
--exact \
-Z unstable-options \
--include-ignored \
--env NUM_PLAYERS=${{ matrix.num-players }}
Automated Test Generation
We use property-based testing to generate valid game scenarios:
#![allow(unused)] fn main() { #[test] fn test_generated_game_scenarios() { // Configure test generation parameters let generator_config = ScenarioGeneratorConfig { num_players: 4, min_turns: 3, max_turns: 10, complexity: TestComplexity::Medium, focus_areas: vec![ FocusArea::Combat, FocusArea::SpellInteraction, FocusArea::CommanderMechanics, ], }; let generator = ScenarioGenerator::new(generator_config); // Generate 5 different test scenarios for i in 0..5 { let scenario = generator.generate_scenario(i); // Use i as seed // Create fresh test context let mut test_context = IntegrationTestContext::new(scenario.num_players); // Setup initial state test_context.app.world.resource_mut::<TestController>() .setup_scenario(&scenario); // Queue all scenario actions for action in &scenario.actions { test_context.queue_action(action.clone()); } // Add verification points from scenario for verification in &scenario.verifications { test_context.add_verification_point(verification.clone()); } // Run the test let result = test_context.run_test(); // Verify all checks passed assert!( result.passed, "Generated scenario {} failed: {:?}", i, result.failed_verifications() ); } } }
This integration testing approach ensures that our networking code and MTG Commander game engine work together seamlessly, providing a robust foundation for online play. By combining structured test scenarios with automated generation, we can comprehensively test the complex interactions between game rules and network communication.
Networking Security Testing
This section covers the security testing approach for the networking components of the MTG Commander game engine.
Overview
Security testing for networking components ensures that the game's online features are protected against various threats and vulnerabilities, maintaining the integrity of gameplay and player data.
Components
Strategy
Detailed strategy for security testing of networking components, including:
- Threat modeling approach
- Vulnerability assessment methodology
- Penetration testing procedures
- Security audit processes
- Compliance verification
- Incident response planning
Testing Scope
The security testing covers:
- Authentication and authorization
- Data encryption and protection
- Input validation and sanitization
- Protection against common attack vectors
- Session management security
- Hidden information protection
- Anti-cheat mechanism validation
Integration with Other Testing
This testing approach integrates with:
- Integration Testing for system-level security validation
- End-to-End Testing for full system security validation
- Security Implementation for validation of security measures
Security Testing for MTG Commander Online
This document outlines the security testing strategy for our MTG Commander online implementation, with a primary focus on protecting hidden information and preventing cheating in a distributed multiplayer environment.
Table of Contents
- Security Objectives
- Threat Model
- Hidden Information Protection
- Client Validation and Server Authority
- Anti-Cheat Testing
- Penetration Testing Methodology
- Fuzzing and Malformed Input Testing
- Session and Authentication Testing
- Continuous Security Testing
Security Objectives
The primary security objectives for our MTG Commander online implementation are:
- Information Confidentiality: Ensuring players only have access to information they're entitled to see
- Game State Integrity: Preventing unauthorized modification of game state
- Rules Enforcement: Ensuring all actions follow MTG rules, even against malicious clients
- Input Validation: Protecting against malicious inputs that could crash or exploit the game
- Fairness: Preventing any player from gaining unfair advantages through technical means
Threat Model
Our threat model considers the following potential adversaries and attack vectors:
Adversaries
- Curious Players: Regular players who might attempt to gain unfair advantages by viewing hidden information
- Cheaters: Players actively attempting to manipulate the game to win unfairly
- Griefers: Players attempting to disrupt gameplay for others without necessarily seeking to win
- Reverse Engineers: Technical users analyzing the client to understand and potentially exploit the protocol
Attack Vectors
- Client Modification: Altering the client to expose hidden information or enable illegal actions
- Network Traffic Analysis: Analyzing network traffic to reveal hidden information
- Protocol Exploitation: Sending malformed or unauthorized messages to the server
- Memory Examination: Using external tools to examine client memory for hidden information
- Timing Attacks: Using timing differences to infer hidden information
Hidden Information Protection
MTG has significant hidden information that must be protected:
- Cards in hand
- Library contents and order
- Facedown cards
- Opponent's choices for effects like scry
Testing Hidden Card Information
#![allow(unused)] fn main() { #[test] fn test_hand_card_information_protection() { let mut app = setup_security_test_app(4); // Setup player 1 with known cards in hand let player1_hand = vec![101, 102, 103]; app.world.resource_mut::<TestController>() .setup_player_hand(1, player1_hand.clone()); // Allow state to replicate app.update_n_times(10); // Verify each client's visibility for client_id in 1..=4 { let client_view = extract_client_game_state(&app, client_id); if client_id == 1 { // Player 1 should see all details of their cards for card_id in &player1_hand { let card = client_view.get_card(*card_id); assert!(card.is_some()); assert_eq!(card.unwrap().visibility_level, VisibilityLevel::FullInformation); } } else { // Other players should only see card backs let opponent_info = client_view.get_opponent_info(1); assert_eq!(opponent_info.hand_size, player1_hand.len()); // Should not have full information about the cards for card_id in &player1_hand { let card = client_view.get_card(*card_id); assert!( card.is_none() || card.unwrap().visibility_level == VisibilityLevel::CardBack, "Client {} can see card {} when they shouldn't", client_id, card_id ); } } } } }
Testing Revealed Card Information
#![allow(unused)] fn main() { #[test] fn test_revealed_card_information() { let mut app = setup_security_test_app(4); // Setup player 1 with known cards in hand let player1_hand = vec![101, 102, 103]; app.world.resource_mut::<TestController>() .setup_player_hand(1, player1_hand.clone()); // Reveal a specific card to all players app.world.resource_mut::<TestController>() .execute_action(GameAction::RevealCard { player_id: 1, card_id: 102, reveal_to: RevealTarget::AllPlayers }); app.update_n_times(10); // Verify correct card is revealed to all players for client_id in 1..=4 { let client_view = extract_client_game_state(&app, client_id); // All players should now see the revealed card let revealed_card = client_view.get_card(102); assert!(revealed_card.is_some()); assert_eq!( revealed_card.unwrap().visibility_level, VisibilityLevel::Revealed ); // Other cards in hand should still be hidden if client_id != 1 { for &card_id in &[101, 103] { let card = client_view.get_card(card_id); assert!( card.is_none() || card.unwrap().visibility_level == VisibilityLevel::CardBack, "Client {} can see unrevealed card {} when they shouldn't", client_id, card_id ); } } } } }
Testing Library Information Protection
#![allow(unused)] fn main() { #[test] fn test_library_information_protection() { let mut app = setup_security_test_app(4); // Setup player 1 with known library let library_cards = (201..240).collect::<Vec<usize>>(); app.world.resource_mut::<TestController>() .setup_player_library(1, library_cards.clone()); app.update_n_times(10); // Verify library information protection for client_id in 1..=4 { let client_view = extract_client_game_state(&app, client_id); // All players should only see library size, not contents let player1_info = if client_id == 1 { client_view.get_player_info() } else { client_view.get_opponent_info(1) }; assert_eq!(player1_info.library_size, library_cards.len()); // No player should see library card details (even the owner) for &card_id in &library_cards { let card = client_view.get_card(card_id); // Card might be visible as existing, but contents should be hidden if card.is_some() { assert!( card.unwrap().visibility_level == VisibilityLevel::CardBack || card.unwrap().zone == Zone::Library, "Client {} can see library card {} contents when they shouldn't", client_id, card_id ); } } } } }
Client Validation and Server Authority
The server must maintain authority over game state and validate all client actions.
Testing Illegal Action Prevention
#![allow(unused)] fn main() { #[test] fn test_illegal_action_prevention() { let mut app = setup_security_test_app(4); // Setup game state with player 1 having some cards but not others app.world.resource_mut::<TestController>() .setup_player_hand(1, vec![101, 102]); app.update_n_times(10); // Test case 1: Try to play a card not in hand let illegal_action = SecurityTestAction::PlayNonExistentCard { player_id: 1, card_id: 999 // Card that doesn't exist }; let result = app.world.resource_mut::<SecurityTestController>() .execute_illegal_action(illegal_action); app.update_n_times(10); // Verify action was rejected assert!(matches!(result, ActionResult::Rejected(rejection) if rejection.reason == RejectionReason::CardNotFound)); // Test case 2: Try to play opponent's card app.world.resource_mut::<TestController>() .setup_player_hand(2, vec![201]); app.update_n_times(10); let illegal_action = SecurityTestAction::PlayOpponentCard { player_id: 1, card_id: 201, // Card in player 2's hand target_player: 2 }; let result = app.world.resource_mut::<SecurityTestController>() .execute_illegal_action(illegal_action); app.update_n_times(10); // Verify action was rejected assert!(matches!(result, ActionResult::Rejected(rejection) if rejection.reason == RejectionReason::NotYourCard)); // Verify game state remains unchanged let game_state = extract_server_game_state(&app); assert!(game_state.players[0].hand.contains(&101)); assert!(game_state.players[0].hand.contains(&102)); assert!(game_state.players[1].hand.contains(&201)); } }
Testing Play Sequence Enforcement
#![allow(unused)] fn main() { #[test] fn test_out_of_sequence_play_prevention() { let mut app = setup_security_test_app(4); // Setup game state where it's player 1's turn, main phase app.world.resource_mut::<TestController>() .setup_game_phase(1, Phase::Main1); app.update_n_times(10); // Try to perform an action when it's not your turn let illegal_action = SecurityTestAction::ActOutOfTurn { player_id: 2, // Not the active player action_type: ActionType::PlayLand, card_id: 201 }; let result = app.world.resource_mut::<SecurityTestController>() .execute_illegal_action(illegal_action); app.update_n_times(10); // Verify action was rejected assert!(matches!(result, ActionResult::Rejected(rejection) if rejection.reason == RejectionReason::NotYourTurn)); // Try to declare attackers during main phase let illegal_action = SecurityTestAction::ActOutOfPhase { player_id: 1, // Correct player action_type: ActionType::DeclareAttackers, }; let result = app.world.resource_mut::<SecurityTestController>() .execute_illegal_action(illegal_action); app.update_n_times(10); // Verify action was rejected assert!(matches!(result, ActionResult::Rejected(rejection) if rejection.reason == RejectionReason::InvalidPhase)); } }
Anti-Cheat Testing
Tests focused specifically on detecting and preventing common cheating methods.
Network Traffic Analysis
#![allow(unused)] fn main() { #[test] fn test_network_traffic_does_not_leak_information() { let mut app = setup_security_test_app(4); // Setup player hands and library for player_id in 1..=4 { app.world.resource_mut::<TestController>() .setup_player_hand(player_id, vec![100 + player_id*10, 101 + player_id*10, 102 + player_id*10]); app.world.resource_mut::<TestController>() .setup_player_library(player_id, (200 + player_id*100..250 + player_id*100).collect()); } // Start network traffic analyzer let mut traffic_analyzer = NetworkTrafficAnalyzer::new(); app.insert_resource(traffic_analyzer.clone()); app.add_systems(Update, analyze_network_traffic); // Run game actions that should generate network traffic let actions = [ GameAction::DrawCard(1), GameAction::PlayLand(1, 110), GameAction::PassPriority(1), GameAction::PassTurn(1), ]; for action in &actions { app.world.resource_mut::<TestController>() .execute_action(action.clone()); app.update_n_times(5); } // Get traffic analysis results let traffic_results = app.world.resource::<NetworkTrafficAnalyzer>().get_results(); // Verify no hidden information is leaked for &player_id in &[2, 3, 4] { // Check player hand cards aren't leaked to others for card_id in 100 + player_id*10..103 + player_id*10 { assert!(!traffic_results.contains_card_information(1, card_id), "Player 1's network traffic contains card {} from player {}'s hand", card_id, player_id); } // Check library card contents aren't leaked for card_id in 200 + player_id*100..250 + player_id*100 { assert!(!traffic_results.contains_card_information(1, card_id), "Player 1's network traffic contains card {} from player {}'s library", card_id, player_id); } } } }
Memory Examination Protection
#![allow(unused)] fn main() { #[test] fn test_client_memory_protection() { // This would be a manual test in practice, documented here for completeness // Steps: // 1. Set up a game with known hidden information // 2. Attach memory examination tools to client process // 3. Scan memory for card identifiers or other game data // 4. Verify sensitive information is obfuscated or encrypted in memory // Implementation would vary based on platform and tooling } }
Penetration Testing Methodology
Structured approach to identifying security vulnerabilities:
#![allow(unused)] fn main() { fn perform_security_penetration_test() { // 1. Information Gathering let game_info = analyze_game_protocol(); // 2. Threat Modeling let attack_vectors = identify_attack_vectors(game_info); // 3. Vulnerability Analysis let vulnerabilities = scan_for_vulnerabilities(attack_vectors); // 4. Exploitation for vulnerability in vulnerabilities { let exploit_result = attempt_exploit(vulnerability); if exploit_result.successful { record_security_issue(exploit_result); } } // 5. Post Exploitation analyze_exploit_impact(); // 6. Reporting generate_security_report(); } }
Example Penetration Test Scenario
#![allow(unused)] fn main() { #[test] fn test_client_message_tampering() { let mut app = setup_security_test_app(4); // Setup initial game state app.world.resource_mut::<TestController>() .setup_standard_game_start(); // Create a message tampering simulator let mut tampering_simulator = MessageTamperingSimulator::new(); app.insert_resource(tampering_simulator); // Register message tampering scenarios app.world.resource_mut::<MessageTamperingSimulator>() .register_tampering_scenario(TamperingScenario::ModifyCardId { original_id: 101, modified_id: 999 }) .register_tampering_scenario(TamperingScenario::InjectAction { action: GameAction::DrawCard(1), injection_point: InjectionPoint::AfterPassPriority }) .register_tampering_scenario(TamperingScenario::ModifyTargetPlayer { original_player: 2, modified_player: 1 }); // Run game with message tampering active let result = run_game_with_tampering(&mut app); // Verify server detected and rejected tampering assert_eq!(result.detected_tampering_count, 3); assert_eq!(result.successful_tampering_count, 0); // Verify game state integrity maintained assert!(result.game_state_integrity_maintained); } }
Fuzzing and Malformed Input Testing
Using automated fuzzing to identify input validation issues:
#![allow(unused)] fn main() { #[test] fn test_protocol_fuzzing() { let mut app = setup_security_test_app(4); // Create fuzzer let fuzzer = ProtocolFuzzer::new() .with_seed(12345) .with_message_types(vec![ MessageType::Action, MessageType::StateUpdate, MessageType::SyncRequest ]) .with_mutation_rate(0.2); // Register fuzzer with app app.insert_resource(fuzzer); app.add_systems(Update, run_protocol_fuzzing); // Run for specified number of iterations for _ in 0..1000 { app.update(); } // Check results let fuzzer = app.world.resource::<ProtocolFuzzer>(); let results = fuzzer.get_results(); // Verify no crashes occurred assert_eq!(results.server_crashes, 0); assert_eq!(results.client_crashes, 0); // Verify all malformed inputs were rejected assert_eq!( results.malformed_messages_sent, results.malformed_messages_rejected, "{} malformed messages were incorrectly accepted", results.malformed_messages_sent - results.malformed_messages_rejected ); } }
Session and Authentication Testing
Verify players can only control their own actions:
#![allow(unused)] fn main() { #[test] fn test_session_integrity() { let mut app = setup_security_test_app(4); // Setup standard game app.world.resource_mut::<TestController>() .setup_standard_game_start(); // Attempt to spoof another player's session let spoofing_result = app.world.resource_mut::<SecurityTestController>() .attempt_session_spoofing(SpoofingAttempt { actual_player: 1, spoofed_player: 2, action: GameAction::PlayLand(2, 201) }); // Verify spoofing was detected and prevented assert!(!spoofing_result.successful); assert_eq!(spoofing_result.detection_reason, DetectionReason::SessionValidationFailed); // Verify game state remained unchanged let game_state = extract_server_game_state(&app); assert!(!game_state.turn_history.contains_action_by_player(2, ActionType::PlayLand)); } }
Continuous Security Testing
Integrated into the CI/CD pipeline:
# .github/workflows/security-tests.yml
name: Security Tests
on:
push:
branches: [ main, develop ]
schedule:
- cron: '0 0 * * *' # Run daily
jobs:
security-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Run information hiding tests
run: cargo test --verbose networking::security::information_hiding
- name: Run protocol validation tests
run: cargo test --verbose networking::security::protocol_validation
- name: Run anti-cheat tests
run: cargo test --verbose networking::security::anti_cheat
- name: Run fuzzing tests (longer runtime)
run: cargo test --verbose networking::security::fuzzing -- --ignored
By implementing these comprehensive security tests, we can ensure our MTG Commander online implementation protects the integrity of the game, prevents cheating, and maintains the proper hidden information characteristics essential to Magic: The Gathering. These tests provide confidence that our networking implementation is not only functional but also secure against potential exploitation attempts.
Security Documentation for MTG Commander Networking
This section covers the security aspects of the MTG Commander game engine's networking implementation. Security is a critical consideration for any multiplayer game, especially one that involves hidden information and competitive gameplay.
Overview
The security implementation for the MTG Commander game engine focuses on several key areas:
- Authentication and Authorization: Ensuring only legitimate users can access the game
- Hidden Information Management: Protecting game-critical hidden information
- Anti-Cheat Measures: Preventing and detecting cheating attempts
- Network Security: Securing communication between clients and servers
- Data Protection: Safeguarding user data and game state
Security Components
Authentication
The Authentication system ensures that only legitimate users can connect to and participate in games. It covers:
- User identity verification
- Session management
- Credential security
- Protection against common authentication attacks
Anti-Cheat
The Anti-Cheat system prevents players from gaining unfair advantages through technical means. It addresses:
- Client modification detection
- Memory manipulation prevention
- Network traffic validation
- Anomaly detection and response
- Enforcement of game rules
Hidden Information Management
The Hidden Information system protects game-critical information that should be hidden from some or all players. It covers:
- Player hand protection
- Library content and order security
- Face-down card management
- Selective information revelation
- Server-side information control
Security Testing
Security testing is a critical aspect of ensuring the robustness of our security measures. For details on how we test security features, see the Security Testing Strategy.
Implementation Principles
Our security implementation follows these core principles:
- Defense in Depth: Multiple layers of security to protect against different types of threats
- Least Privilege: Components only have access to the information and capabilities they need
- Server Authority: The server is the single source of truth for game state
- Secure by Default: Security is built into the system from the ground up
- Continuous Improvement: Security measures are regularly reviewed and enhanced
Future Enhancements
Planned security enhancements include:
- Enhanced encryption for sensitive game actions
- Two-factor authentication support
- Advanced anti-cheat measures using machine learning
- Improved security testing automation
- Expanded security documentation and best practices
This documentation will be updated as security measures evolve.
Anti-Cheat Measures in MTG Commander Game Engine
This document outlines the anti-cheat mechanisms implemented in the MTG Commander game engine to ensure fair gameplay in multiplayer sessions.
Overview
Preventing cheating is essential for maintaining a fair and enjoyable multiplayer experience. The MTG Commander game engine implements several layers of anti-cheat measures to detect and prevent various forms of cheating.
Types of Cheating Addressed
- Client Modification: Tampering with the game client to gain advantages
- Memory Manipulation: Directly modifying game memory to alter game state
- Network Manipulation: Intercepting and modifying network traffic
- Information Exposure: Accessing hidden information (opponent's hands, library order)
- Automation/Botting: Using automated tools to play the game
Implementation Approach
Server Authority Model
The game uses a server-authoritative model where critical game state and rules enforcement happens on the server:
#![allow(unused)] fn main() { // Server-side validation of player actions fn validate_play_card_action( mut commands: Commands, mut server: ResMut<RepliconServer>, mut play_events: EventReader<PlayCardEvent>, game_states: Query<&GameState>, players: Query<(Entity, &Player, &Hand)>, ) { for event in play_events.read() { let client_id = event.client_id; let card_id = event.card_id; // Find the player entity for this client if let Some((player_entity, player, hand)) = players .iter() .find(|(_, player, _)| player.client_id == client_id) { // Verify the player actually has this card in hand if !hand.cards.contains(&card_id) { // Log potential cheating attempt warn!("Potential cheat detected: Client {} attempted to play card {} not in hand", client_id, card_id); // Send cheat detection notification server.send_message(client_id, CheatWarning { reason: "Attempted to play card not in hand".to_string() }); // Skip processing this invalid action continue; } // Continue with normal processing if validation passes // ... } } } }
Client-Side Integrity Checks
The client includes integrity verification to detect tampering:
#![allow(unused)] fn main() { // Client-side integrity check system fn verify_client_integrity( mut integrity_state: ResMut<IntegrityState>, mut client: ResMut<RepliconClient>, ) { // Perform integrity checks let checksum = calculate_code_checksum(); // If checksum doesn't match expected value if checksum != integrity_state.expected_checksum { // Report integrity failure to server client.send_message(IntegrityFailure { reason: "Client code integrity check failed".to_string(), details: format!("Expected: {}, Got: {}", integrity_state.expected_checksum, checksum), }); // Set integrity failure state integrity_state.integrity_failed = true; } } }
Network Traffic Validation
All network traffic is validated for consistency and authenticity:
#![allow(unused)] fn main() { // Server-side network traffic validation fn validate_network_messages( mut server: ResMut<RepliconServer>, mut message_events: EventReader<NetworkMessageEvent>, client_states: Query<&ClientState>, ) { for event in message_events.read() { // Verify message sequence is correct (no missing messages) if let Ok(client_state) = client_states.get(event.client_entity) { if event.sequence_number != client_state.next_expected_sequence { // Potential replay or injection attack warn!("Sequence mismatch for client {}: Expected {}, got {}", event.client_id, client_state.next_expected_sequence, event.sequence_number); // Take appropriate action (request resync, disconnect, etc.) // ... } } // Verify message signature if using signed messages if !verify_message_signature(&event.message, &event.signature) { // Message tampering detected warn!("Invalid message signature from client {}", event.client_id); // Take appropriate action // ... } } } }
Detection and Response
Anomaly Detection
The system monitors for statistical anomalies and suspicious patterns:
- Unusual timing between actions
- Statistically improbable sequences of draws or plays
- Impossible knowledge of hidden information
Response to Detected Cheating
When potential cheating is detected, the system can respond in several ways:
- Warning: For minor or first offenses
- Game Termination: Ending the current game
- Temporary Ban: Restricting access for a period
- Permanent Ban: Blocking the user entirely
- Silent Monitoring: Continuing to monitor without immediate action
Testing Anti-Cheat Measures
Testing the anti-cheat system involves:
- Simulated Attacks: Attempting various cheating methods in a controlled environment
- Penetration Testing: Having security experts attempt to bypass protections
- False Positive Analysis: Ensuring legitimate players aren't falsely flagged
For detailed testing procedures, see the Security Testing Strategy.
Future Enhancements
Planned improvements to the anti-cheat system include:
- Machine learning-based anomaly detection
- Enhanced client-side protection against memory editing
- Improved server-side validation of complex game states
- Community reporting and review system
This documentation will be updated as anti-cheat measures evolve.
Data Validation
Encryption
Authentication in MTG Commander Game Engine
This document outlines the authentication mechanisms used in the MTG Commander game engine's multiplayer implementation.
Overview
Authentication is a critical component of the networking system, ensuring that only authorized users can connect to and participate in games. The system uses bevy_replicon along with custom authentication logic to establish secure connections.
Authentication Flow
- User Login: Players begin by providing credentials or connecting via a trusted third-party service
- Credential Validation: Server validates credentials against stored records or third-party responses
- Session Token Generation: Upon successful validation, a unique session token is generated
- Client Authentication: The client uses this token for all subsequent communications
- Session Management: Server tracks active sessions and handles expiration/revocation
Implementation Details
Server-Side Authentication
#![allow(unused)] fn main() { // Authentication handler on server fn handle_authentication( mut commands: Commands, mut server: ResMut<ReplicionServer>, mut auth_events: EventReader<AuthenticationEvent>, ) { for event in auth_events.read() { // Validate credentials if validate_credentials(&event.credentials) { // Generate session token let session_token = generate_session_token(); // Store session information commands.spawn(( SessionData { user_id: event.user_id, token: session_token.clone(), expiration: Utc::now() + Duration::hours(24), }, Replicated, )); // Send authentication response server.send_message(event.client_id, AuthSuccess { token: session_token }); } else { // Failed authentication server.send_message(event.client_id, AuthFailure { reason: "Invalid credentials".to_string() }); } } } }
Client-Side Authentication
#![allow(unused)] fn main() { // Client authentication system fn authenticate_client( mut client: ResMut<RepliconClient>, mut auth_state: ResMut<AuthenticationState>, keyboard: Res<Input<KeyCode>>, mut ui_state: ResMut<UiState>, ) { // Handle authentication flow based on state match *auth_state { AuthenticationState::NotAuthenticated => { if ui_state.credentials_ready { // Send credentials to server client.send_message(AuthRequest { username: ui_state.username.clone(), password: ui_state.password.clone(), }); *auth_state = AuthenticationState::Authenticating; } }, AuthenticationState::Authenticating => { // Wait for server response (handled in another system) }, AuthenticationState::Authenticated => { // Authentication complete, proceed to lobby or game } } } }
Security Considerations
Token Security
- Session tokens are cryptographically secure random values
- Tokens are transmitted securely and stored with appropriate protections
- Token lifetimes are limited and can be revoked if suspicious activity is detected
Password Security
- Passwords are never stored in plaintext
- Argon2id hashing with appropriate parameters is used for password storage
- Password policy enforces minimum complexity requirements
Protection Against Common Attacks
- Rate limiting is implemented to prevent brute force attempts
- Protection against replay attacks using token rotation and nonces
- Secure communication channel to prevent man-in-the-middle attacks
Future Enhancements
- OAuth integration for third-party authentication
- Two-factor authentication for additional security
- Hardware token support for high-security environments
- Biometric authentication for supported platforms
Testing the Authentication System
Comprehensive testing is critical for authentication systems. See the Security Testing Strategy for details on the testing approach for authentication.
This documentation will be updated as the authentication system evolves.
Hidden Information Management in MTG Commander
This document outlines the approach to managing hidden information in the MTG Commander game engine's multiplayer implementation.
Overview
Magic: The Gathering relies heavily on hidden information as a core gameplay mechanic. Cards in players' hands, the order of libraries, and face-down cards are all examples of information that must be hidden from some or all players. Properly securing this information is critical to maintaining game integrity.
Types of Hidden Information
- Player Hands: Cards in a player's hand should be visible only to that player
- Libraries: The order and content of libraries should be hidden from all players
- Face-down Cards: Cards played face-down should have their identity hidden
- Revealed Cards: Cards revealed to specific players should only be visible to those players
- Search Results: When a player searches their library, only they should see the results
Implementation Approach
Server-Side Information Management
The server maintains the authoritative game state and controls what information is sent to each client:
#![allow(unused)] fn main() { // System to update visible information for each client fn update_client_visible_information( mut server: ResMut<RepliconServer>, game_state: Res<GameState>, players: Query<(Entity, &Player, &Hand, &Library)>, connected_clients: Res<ConnectedClients>, ) { // For each connected client for client_id in connected_clients.clients.keys() { // Find the player entity for this client let player_entity = players .iter() .find(|(_, player, _, _)| player.client_id == *client_id) .map(|(entity, _, _, _)| entity); // Prepare visible game state for this client let mut visible_state = VisibleGameState { // Common visible information battlefield: game_state.battlefield.clone(), graveyards: game_state.graveyards.clone(), exiled_cards: game_state.exiled_cards.clone(), // Player-specific information player_hands: HashMap::new(), library_counts: HashMap::new(), library_tops: HashMap::new(), }; // Add information about each player's hand and library for (entity, player, hand, library) in players.iter() { // If this is the current player, show their full hand if Some(entity) == player_entity { visible_state.player_hands.insert(player.id, hand.cards.clone()); } else { // For other players, only show card count visible_state.player_hands.insert(player.id, vec![CardBack; hand.cards.len()]); } // For all players, only show library count, not contents visible_state.library_counts.insert(player.id, library.cards.len()); // Show top card if it's been revealed if library.top_revealed { visible_state.library_tops.insert(player.id, library.cards.first().cloned()); } } // Send the visible state to this client server.send_message(*client_id, ClientGameState { state: visible_state }); } } }
Client-Side Information Handling
The client displays only the information it receives from the server:
#![allow(unused)] fn main() { // Client system to update UI based on visible information fn update_game_ui( visible_state: Res<VisibleGameState>, mut ui_state: ResMut<UiState>, local_player_id: Res<LocalPlayerId>, ) { // Update battlefield display ui_state.battlefield_cards = visible_state.battlefield.clone(); // Update hand display (only shows full information for local player) if let Some(hand) = visible_state.player_hands.get(&local_player_id.0) { ui_state.hand_cards = hand.clone(); } // Update opponent hand displays (only shows card backs) for (player_id, hand) in visible_state.player_hands.iter() { if *player_id != local_player_id.0 { ui_state.opponent_hand_counts.insert(*player_id, hand.len()); } } // Update library displays (only shows counts) for (player_id, count) in visible_state.library_counts.iter() { ui_state.library_counts.insert(*player_id, *count); } // Update revealed top cards if any for (player_id, card) in visible_state.library_tops.iter() { if let Some(card) = card { ui_state.revealed_tops.insert(*player_id, card.clone()); } } } }
Security Measures
Encryption
All network traffic containing hidden information is encrypted:
#![allow(unused)] fn main() { // Example of encrypting sensitive game data fn encrypt_game_message( message: &GameMessage, encryption_key: &EncryptionKey, ) -> EncryptedMessage { // Serialize the message let serialized = bincode::serialize(message).expect("Failed to serialize message"); // Encrypt the serialized data let nonce = generate_nonce(); let cipher = ChaCha20Poly1305::new(encryption_key.as_ref().into()); let ciphertext = cipher .encrypt(&nonce, serialized.as_ref()) .expect("Encryption failed"); EncryptedMessage { ciphertext, nonce, } } }
Server Authority
The server is the single source of truth for all game state:
- Clients never directly modify hidden information
- All actions that would reveal information are processed by the server
- The server controls exactly what information each client can see
Validation
The server validates all client actions to ensure they don't attempt to access hidden information:
#![allow(unused)] fn main() { // Validate a player's attempt to look at cards fn validate_look_at_cards_action( mut commands: Commands, mut server: ResMut<RepliconServer>, mut look_events: EventReader<LookAtCardsEvent>, game_state: Res<GameState>, players: Query<(Entity, &Player)>, ) { for event in look_events.read() { let client_id = event.client_id; let target_zone = event.zone; let target_player_id = event.player_id; // Check if this action is allowed by a game effect let is_allowed = game_state .active_effects .iter() .any(|effect| effect.allows_looking_at(target_zone, target_player_id, client_id)); if !is_allowed { // Log potential information leak attempt warn!("Potential information leak attempt: Client {} tried to look at {:?} of player {} without permission", client_id, target_zone, target_player_id); // Reject the action server.send_message(client_id, ActionRejected { reason: "Not allowed to look at those cards".to_string() }); continue; } // Process the legitimate look action // ... } } }
Testing Hidden Information Security
Testing the hidden information system involves:
- Penetration Testing: Attempting to access hidden information through various attack vectors
- Protocol Analysis: Examining network traffic to ensure hidden information isn't leaked
- Edge Case Testing: Testing unusual game states that might reveal hidden information
For detailed testing procedures, see the Security Testing Strategy.
Future Enhancements
Planned improvements to hidden information management include:
- Enhanced encryption for particularly sensitive game actions
- Improved obfuscation of client-side game state
- Additional validation for complex card interactions involving hidden information
- Support for spectator mode with appropriate information hiding
This documentation will be updated as hidden information management evolves.
UI Integration
Game Engine
This section covers the core game engine that powers Rummage, the Magic: The Gathering Commander format implementation built with Bevy 0.15.x.
Architecture Overview
The Rummage game engine is built on Bevy's Entity Component System (ECS) architecture, providing a robust foundation for implementing the complex rules and interactions of Magic: The Gathering. The engine is designed with the following principles:
- Separation of Concerns: Game logic is separated from rendering and input handling
- Data-Oriented Design: Game state is represented as components on entities
- Event-Driven Architecture: Systems communicate through events
- Deterministic Execution: Game logic runs deterministically for network play
- Extensible Systems: New cards and mechanics can be added without modifying core systems
Core Components
The game engine consists of several interconnected systems:
- State Management: How game state is tracked and updated
- Entity and component management
- Game state progression
- Player state handling
- Event System: How game events are processed and handled
- Event dispatching
- Event handling
- Custom event types
- Snapshot System: How game state is serialized for networking and replays
- State serialization
- Deterministic snapshots
- Replay functionality
Integration Points
The game engine integrates with several other systems:
- Card Systems: Card representation and effects
- MTG Rules: Implementation of game rules
- UI Systems: Visual representation and interaction
- Networking: Multiplayer functionality
Plugin Structure
The game engine is organized as a set of Bevy plugins that can be added to your Bevy application:
#![allow(unused)] fn main() { // Initialize the game engine App::new() .add_plugins(DefaultPlugins) .add_plugins(RummageGameEnginePlugin) .run(); }
Implementation Status
The game engine currently implements:
- ✅ Core turn structure
- ✅ Basic card mechanics
- ✅ Zone management
- 🔄 Stack implementation
- 🔄 Combat system
- ⚠️ Comprehensive rules coverage
- ⚠️ Advanced card interactions
Extending the Engine
For information on extending the game engine with new cards or mechanics, see the Development Guide.
State Management
The state management system is responsible for tracking, updating, and maintaining the consistency of game state in Rummage. It represents all the information needed to describe the current state of a Magic: The Gathering game.
State Components
Game state is divided into several major components:
Player State
Players have various attributes tracked by the state system:
- Life totals: Current life points
- Mana pool: Available mana for casting spells
- Hand: Cards in the player's hand
- Library: Cards in the player's deck
- Graveyard: Cards in the player's discard pile
- Commander zone: Special zone for Commander cards
Card State
Cards have multiple state properties:
- Zone location: Current game zone (hand, battlefield, etc.)
- Physical state: Tapped/untapped, face-up/face-down, etc.
- Counters: Various counters on the card
- Attached entities: Equipment, Auras, etc.
- Temporary effects: Effects currently modifying the card
Game Flow State
The overall game flow has state:
- Current turn: Which player's turn it is
- Phase/step: Current phase and step in the turn
- Priority holder: Which player currently has priority
- Stack: Spells and abilities waiting to resolve
ECS Implementation
State is implemented using Bevy's Entity Component System:
- Entities: Cards, players, and other game objects
- Components: State attributes attached to entities
- Systems: Logic that operates on components
- Resources: Global state shared across entities
Example Implementation
Here's a simplified example of how player state might be implemented:
#![allow(unused)] fn main() { // Player life component #[derive(Component)] pub struct Life { pub current: u32, pub maximum: u32, } // Player mana pool component #[derive(Component)] pub struct ManaPool { pub white: u32, pub blue: u32, pub black: u32, pub red: u32, pub green: u32, pub colorless: u32, } // System to update life totals fn update_life_system( mut player_query: Query<&mut Life>, mut life_events: EventReader<LifeChangeEvent>, ) { for event in life_events.read() { if let Ok(mut life) = player_query.get_mut(event.player_entity) { life.current = (life.current as i32 + event.amount).max(0) as u32; } } } }
State Change Process
State changes follow a specific process:
- Event-driven: Changes are triggered by events
- Validation: Changes are validated against game rules
- Execution: Changes are applied to components
- Side effects: Changes may trigger additional events
- State-based actions: After each change, state-based actions are checked
Integration with Rules
The state system is tightly integrated with the MTG rules implementation:
- Rules define valid state transitions
- State-based actions maintain invariants
- Turn structure progresses through defined states
Persistence
The state system supports:
- Serialization: Convert state to data format for storage
- Deserialization: Restore state from data
- Snapshots: Capture state at a point in time
- Rollback: Restore to a previous state if needed
Networking
For multiplayer games, state is synchronized across clients. See Networking for details on how state is kept consistent across the network.
Related Components
The state system works closely with:
- Event System: Events trigger state changes
- Snapshot System: Captures and restores state
- Card Effects: Effects modify state
Subgames and Game Restarting Implementation
This document outlines the technical implementation of subgames (like those created by Shahrazad) and game restarting mechanics (like Karn Liberated's ultimate ability) within our engine.
Component Design
The implementation uses several key components and resources to manage subgames and game restarting:
#![allow(unused)] fn main() { // Tracks whether we're currently in a subgame #[derive(Resource)] pub struct SubgameState { /// Stack of game states, with the main game at the bottom pub game_stack: Vec<GameSnapshot>, /// Current subgame depth (0 = main game) pub depth: usize, } // Marks cards that were exiled by Karn Liberated #[derive(Component)] pub struct ExiledWithKarn; // Component that can restart the game #[derive(Component)] pub struct GameRestarter { pub condition: GameRestarterCondition, } // Enum defining what triggers game restart pub enum GameRestarterCondition { KarnUltimate, Custom(Box<dyn Fn(&World) -> bool + Send + Sync + 'static>), } }
Subgame Implementation
Subgames are implemented using a stack-based approach that leverages our snapshot system:
Starting a Subgame
When a card like Shahrazad resolves:
- The current game state is captured via the snapshot system
- The snapshot is pushed onto the
game_stack
inSubgameState
- The depth counter is incremented
- A new game state is initialized with the appropriate starting conditions
- Players' libraries from the main game become their decks in the subgame
#![allow(unused)] fn main() { fn start_subgame( mut commands: Commands, mut subgame_state: ResMut<SubgameState>, snapshot_system: Res<SnapshotSystem>, // Other dependencies... ) { // Take snapshot of current game let current_game = snapshot_system.capture_game_state(); // Push to stack and increment depth subgame_state.game_stack.push(current_game); subgame_state.depth += 1; // Initialize new game state for subgame... // Transfer libraries from parent game to new subgame... } }
Ending a Subgame
When a subgame concludes:
- The result of the subgame is determined (winner/loser)
- The most recent snapshot is popped from the
game_stack
- The depth counter is decremented
- The main game state is restored from the snapshot
- Any effects from the subgame are applied to the main game (loser loses half life)
#![allow(unused)] fn main() { fn end_subgame( mut commands: Commands, mut subgame_state: ResMut<SubgameState>, snapshot_system: Res<SnapshotSystem>, game_result: Res<SubgameResult>, // Other dependencies... ) { // Pop game state and decrement depth let parent_game = subgame_state.game_stack.pop().unwrap(); subgame_state.depth -= 1; // Restore parent game state snapshot_system.restore_game_state(parent_game); // Apply subgame results to parent game // (loser loses half their life, rounded up)... } }
Game Restarting Implementation
Game restarting is a complete reinitialization of the game state with some information carried over:
Tracking Exiled Cards
Cards exiled with abilities like Karn Liberated's are marked with special components:
#![allow(unused)] fn main() { fn track_karn_exile( mut commands: Commands, karn_query: Query<Entity, With<KarnLiberated>>, exile_events: EventReader<CardExiledEvent>, ) { for event in exile_events.read() { if event.source_ability == AbilityType::KarnExile { commands.entity(event.card_entity).insert(ExiledWithKarn); } } } }
Restarting the Game
When a game restart ability triggers:
- Cards exiled with Karn are identified and stored in a temporary resource
- All existing game resources are cleaned up
- A new game is initialized with standard starting conditions
- The exiled cards are placed in their owners' hands in the new game
#![allow(unused)] fn main() { fn restart_game( mut commands: Commands, restart_events: EventReader<GameRestartEvent>, exiled_cards: Query<(Entity, &Owner), With<ExiledWithKarn>>, // Other dependencies... ) { if restart_events.is_empty() { return; } // Store information about exiled cards let cards_to_return = exiled_cards .iter() .map(|(entity, owner)| (entity, owner.0)) .collect::<Vec<_>>(); // Clean up existing game state... // Initialize new game... // Return exiled cards to their owners' hands for (card_entity, owner_id) in cards_to_return { // Move card to owner's hand in the new game // ... } } }
Interacting with Game Systems
Both subgames and game restarting interact with several core systems:
Snapshot System Integration
The Snapshot System is crucial for both features:
- Subgames use it to preserve and restore game states
- Game restarting uses it to track information that needs to persist through restarts
Turn System Integration
The turn system needs special handling for subgames:
- Subgames have their own turn structure independent of the main game
- When returning from a subgame, the turn structure of the main game must be properly restored
Zone Management
Both features require special zone handling:
- Subgames need to track cards that leave the subgame
- Game restarting needs to move cards to their proper starting zones
Error Handling and Edge Cases
The implementation includes handling for various edge cases:
- Nested subgames (subgames within subgames)
- Game restarts during a subgame
- Subgame creation during a game restart
- Corrupted state recovery
- Performance considerations for deep subgame nesting
See the MTG Rules documentation for more information on the rules governing these mechanics.
Event System
The Rummage event system is a core component of the game engine that enables communication between different systems and components in a decoupled manner. Events are used to notify about changes in game state, trigger effects, and coordinate actions between different parts of the codebase.
Event Architecture
The event system is built on Bevy's native event system, which provides:
- Type-safe events: Each event is a strongly-typed struct
- Single-frame lifetime: Events are processed within a single frame by default
- Multiple readers: Multiple systems can respond to the same events
- Ordered processing: Events are processed in a deterministic order
Core Event Types
Rummage implements several categories of events:
Game Flow Events
Events that control the progression of the game:
TurnStartEvent
: Signals the start of a new turnPhaseChangeEvent
: Indicates a change in the current phaseStepChangeEvent
: Indicates a change in the current stepPriorityPassedEvent
: Signals when priority is passed between players
Card Events
Events related to card actions:
CardPlayedEvent
: Triggered when a card is played from handCardMovedEvent
: Signals when a card changes zonesCardStateChangedEvent
: Indicates a change in a card's state (tapped, etc.)CardTargetedEvent
: Triggered when a card is targeted by a spell or ability
Player Events
Events related to player actions and state:
PlayerDamageEvent
: Signals when a player takes damagePlayerGainLifeEvent
: Triggered when a player gains lifePlayerDrawCardEvent
: Indicates a card drawPlayerLosesEvent
: Signals when a player loses the game
Custom Events
Game mechanics and card effects often require custom events. These can be created by defining a new event struct:
#![allow(unused)] fn main() { #[derive(Event)] pub struct ExileCardEvent { pub card_entity: Entity, pub source_entity: Option<Entity>, pub until_end_of_turn: bool, } }
Event Processing
Events are processed using Bevy's event readers:
#![allow(unused)] fn main() { fn process_card_played_events( mut event_reader: EventReader<CardPlayedEvent>, mut commands: Commands, // other system parameters ) { for event in event_reader.read() { // React to the card being played // Trigger effects, update state, etc. } } }
Event Broadcasting
Systems can broadcast events using Bevy's event writers:
#![allow(unused)] fn main() { fn play_card_system( mut commands: Commands, mut event_writer: EventWriter<CardPlayedEvent>, // other system parameters ) { // Logic to determine a card is played // Broadcast the event event_writer.send(CardPlayedEvent { card_entity, player_entity, from_zone: Zone::Hand, }); } }
Event-Driven Architecture
The event system enables an event-driven architecture where:
- Actions in the game broadcast events
- Systems listen for relevant events
- Events trigger state changes and additional events
- Complex interactions emerge from simple event chains
This approach simplifies the implementation of MTG's complex rules and card interactions.
Integration
The event system integrates with:
- State Management: Events trigger state changes
- MTG Rules: Rules are implemented as event handlers
- Network System: Events are serialized for network play
For more information on implementing card effects using events, see Card Effects.
Snapshot System
The snapshot system is a core component of the Rummage game engine that provides serialization and deserialization of game state. This system is essential for networking, replay functionality, and save/load capabilities.
Overview
The snapshot system enables:
- Networked Multiplayer: Synchronizing game state between players
- Replay System: Recording and replaying games
- Undo Functionality: Allowing players to revert to previous game states
- Save/Load: Persisting game state between sessions
- Crash Recovery: Automatically recovering from application crashes
Documentation Sections
This documentation covers the following aspects of the snapshot system:
- Overview: Detailed introduction to the snapshot concept and architecture
- Implementation: Technical details of the snapshot implementation
- Integration with Networking: How snapshots are used in the multiplayer system
- Persistent Storage: Using bevy_persistent for robust state persistence
- Testing: Approaches to testing snapshot functionality
- API Reference: Complete reference of snapshot-related types and functions
Technical Architecture
The snapshot system uses a component-based approach to serialize and deserialize entities in the game world. It captures the state of all relevant entities and components at a specific point in time, allowing the game state to be reconstructed later.
Key Features
- Selective Serialization: Only relevant components are included in snapshots
- Efficient Storage: Compact binary representation of game state
- Event-Based Triggering: Snapshots can be triggered by game events
- Queue Management: Processing of snapshots is managed to avoid performance impact
- Integration Points: Well-defined interfaces for networking and replay systems
- Persistent Storage: Robust save/load functionality with automatic recovery
Usage in Rummage
The snapshot system is used throughout Rummage:
- Networking: Synchronizing game state between clients
- Game History: Recording turn-by-turn snapshots for replay and analysis
- Testing: Verifying game state correctness in unit and integration tests
- Save/Load: Allowing games to be saved and resumed later
- Crash Recovery: Automatically restoring state after unexpected crashes
- Rollback: Supporting undo functionality for players
bevy_persistent Integration
The snapshot system leverages the bevy_persistent
crate to provide robust save/load functionality:
- Automatic Persistence: Game state is automatically saved at key moments
- Error Recovery: Comprehensive error handling for corrupted saves
- Efficient Format: Optimized binary serialization for large game states
- Incremental Saves: Only changed data is saved to improve performance
- Cross-Platform: Works consistently across all supported platforms
See the detailed documentation sections for more information on each aspect of the snapshot system.
Snapshot System Overview
Introduction
A snapshot in Rummage is a serializable representation of the game state at a specific point in time. It captures all the information needed to reproduce the exact state of the game, including cards, player data, and game progress.
Core Concepts
What is a Snapshot?
A snapshot captures:
- Entities: Game objects like cards, players, and zones
- Components: Properties and state associated with entities
- Resources: Global game state and configuration
- Relationships: Connections between entities (e.g., a card in a zone)
When Are Snapshots Created?
Snapshots can be created:
- On Turn Change: Capture state at the beginning of each turn
- On Phase Change: Record state at critical phase transitions
- On Demand: Manually triggered for testing or replay purposes
- Before Network Updates: Prior to sending state updates to clients
- At Save Points: When a user wants to save game progress
Snapshot Lifecycle
The typical lifecycle of a snapshot is:
- Creation: Game state is captured and serialized
- Storage: The snapshot is stored in memory or on disk
- Processing: Various systems may analyze or transform the snapshot
- Application: The snapshot is used to restore game state (for replay, rollback, etc.)
- Disposal: Old snapshots are removed when no longer needed
Technical Details
Core Types
The main types in the snapshot system are:
#![allow(unused)] fn main() { /// A serializable snapshot of the game state #[derive(Serialize, Deserialize, Clone, Debug)] pub struct GameSnapshot { /// Unique identifier for the snapshot pub id: Uuid, /// The game turn the snapshot was taken on pub turn: u32, /// The phase within the turn pub phase: Phase, /// Active player when snapshot was taken pub active_player: usize, /// Serialized game data pub game_data: HashMap<String, Vec<u8>>, /// Timestamp when the snapshot was created pub timestamp: f64, } /// Tracks pending snapshot operations #[derive(Resource, Default)] pub struct PendingSnapshots { /// Snapshots waiting to be processed pub queue: VecDeque<GameSnapshot>, /// Whether snapshot processing is paused pub paused: bool, } /// Marker for entities that should be included in snapshots #[derive(Component)] pub struct Snapshotable; }
Serialization Strategy
The snapshot system uses a selective serialization strategy:
- Marker Components: Only entities with
Snapshotable
components are included - Component Filtering: Only necessary components are serialized
- Binary Encoding: Data is encoded efficiently to minimize size
- ID Mapping: Entity IDs are mapped to ensure consistency across sessions
Use Cases
Networked Multiplayer
In multiplayer games, snapshots are used to:
- Synchronize game state between clients
- Verify state consistency across the network
- Handle player disconnections and reconnections
- Provide authoritative state for conflict resolution
Replay System
For game replays, snapshots enable:
- Recording complete game history
- Navigating back and forth through game turns
- Analyzing gameplay decisions
- Sharing interesting game states
Save/Load Functionality
Snapshots make save/load possible by:
- Capturing all necessary data to resume play
- Creating portable save files
- Supporting different save points within a game
- Ensuring version compatibility
Testing
For testing purposes, snapshots allow:
- Creating reproducible test scenarios
- Verifying state transitions
- Validating rule implementations
- Comparing expected vs. actual outcomes
Next Steps
For more detailed information, continue to:
- Implementation: The technical implementation details
- Integration with Networking: How snapshots work with multiplayer
- Testing: How to test snapshot functionality
- API Reference: Complete API documentation
Snapshot System Implementation
This document covers the technical implementation details of the Rummage snapshot system.
Plugin Structure
The snapshot system is implemented as a Bevy plugin that can be added to the application:
#![allow(unused)] fn main() { pub struct SnapshotPlugin; impl Plugin for SnapshotPlugin { fn build(&self, app: &mut App) { app // Register resources .init_resource::<PendingSnapshots>() .init_resource::<SnapshotConfig>() // Register snapshot events .add_event::<SnapshotEvent>() .add_event::<SnapshotProcessedEvent>() // Add snapshot systems to the appropriate schedule .add_systems(Update, ( handle_snapshot_events, process_pending_snapshots, trigger_snapshot_on_turn_change, trigger_snapshot_on_phase_change, ).chain()); } } }
Core Components
Configuration
The snapshot system is configured through a resource:
#![allow(unused)] fn main() { #[derive(Resource)] pub struct SnapshotConfig { /// Whether to automatically create snapshots on turn changes pub auto_snapshot_on_turn: bool, /// Whether to automatically create snapshots on phase changes pub auto_snapshot_on_phase: bool, /// Maximum number of snapshots to process per frame pub max_snapshots_per_frame: usize, /// Maximum number of snapshots to keep in history pub max_history_size: usize, /// Whether to compress snapshots pub use_compression: bool, } impl Default for SnapshotConfig { fn default() -> Self { Self { auto_snapshot_on_turn: true, auto_snapshot_on_phase: false, max_snapshots_per_frame: 1, max_history_size: 100, use_compression: true, } } } }
Event Types
The system communicates through events:
#![allow(unused)] fn main() { /// Events for snapshot operations #[derive(Event)] pub enum SnapshotEvent { /// Take a new snapshot of the current state Take, /// Apply a specific snapshot Apply(Uuid), /// Save the current snapshot to disk Save(String), /// Load a snapshot from disk Load(String), } /// Event fired when a snapshot has been processed #[derive(Event)] pub struct SnapshotProcessedEvent { /// The ID of the processed snapshot pub id: Uuid, /// Whether processing succeeded pub success: bool, } }
Creating Snapshots
The core snapshot creation logic:
#![allow(unused)] fn main() { fn create_game_snapshot( world: &World, game_state: &GameState, ) -> GameSnapshot { // Create a new empty snapshot let mut snapshot = GameSnapshot { id: Uuid::new_v4(), turn: game_state.turn, phase: game_state.phase.clone(), active_player: game_state.active_player, game_data: HashMap::new(), timestamp: world.resource::<Time>().elapsed_seconds(), }; // Find all snapshotable entities let mut snapshotable_query = world.query_filtered::<Entity, With<Snapshotable>>(); // For each snapshotable entity, serialize its components for entity in snapshotable_query.iter(world) { serialize_entity_to_snapshot(world, entity, &mut snapshot); } snapshot } fn serialize_entity_to_snapshot( world: &World, entity: Entity, snapshot: &mut GameSnapshot, ) { // Get all component types registered for this entity let entity_components = world.entity(entity).archetype().components(); // Create a buffer to store the entity's serialized components let mut entity_data = Vec::new(); // Write the entity ID let entity_id = entity.to_bits(); entity_data.extend_from_slice(&entity_id.to_le_bytes()); // For each component type for component_id in entity_components.iter() { // Skip certain component types that don't need to be serialized if should_skip_component(component_id) { continue; } // Get the component storage if let Some(component_info) = world.components().get_info(component_id) { // Get the component data for this entity if let Some(component_data) = component_info.get_component(world, entity) { // Write the component ID entity_data.extend_from_slice(&component_id.to_le_bytes()); // Write the component size let size = component_data.len(); entity_data.extend_from_slice(&size.to_le_bytes()); // Write the component data entity_data.extend_from_slice(component_data); } } } // Add the entity's data to the snapshot snapshot.game_data.insert(entity.index().to_string(), entity_data); } }
Automatic Snapshot Triggering
Snapshots can be automatically triggered by game events:
#![allow(unused)] fn main() { fn trigger_snapshot_on_turn_change( mut turn_events: EventReader<TurnChangeEvent>, mut snapshot_events: EventWriter<SnapshotEvent>, config: Res<SnapshotConfig>, ) { if !config.auto_snapshot_on_turn { return; } for _ in turn_events.iter() { // Create a new snapshot event snapshot_events.send(SnapshotEvent::Take); } } fn trigger_snapshot_on_phase_change( mut phase_events: EventReader<PhaseChangeEvent>, mut snapshot_events: EventWriter<SnapshotEvent>, config: Res<SnapshotConfig>, ) { if !config.auto_snapshot_on_phase { return; } for _ in phase_events.iter() { // Create a new snapshot event snapshot_events.send(SnapshotEvent::Take); } } }
Processing Snapshots
The process_pending_snapshots
system handles snapshots in the pending queue:
#![allow(unused)] fn main() { /// Process any pending snapshots in the queue pub fn process_pending_snapshots( mut commands: Commands, mut pending: ResMut<PendingSnapshots>, mut processed_events: EventWriter<SnapshotProcessedEvent>, config: Res<SnapshotConfig>, ) { // Skip if processing is paused if pending.paused { return; } // Process up to config.max_snapshots_per_frame let to_process = pending.queue.len().min(config.max_snapshots_per_frame); for _ in 0..to_process { if let Some(snapshot) = pending.queue.pop_front() { // Apply the snapshot to the game state apply_snapshot(&mut commands, &snapshot); // Notify that a snapshot was processed processed_events.send(SnapshotProcessedEvent { id: snapshot.id, success: true, }); } } } }
Applying Snapshots
To restore a game state from a snapshot:
#![allow(unused)] fn main() { fn apply_snapshot( commands: &mut Commands, snapshot: &GameSnapshot, ) { // Clear existing entities that should be replaced by the snapshot clear_snapshotable_entities(commands); // Restore global game state restore_game_state(commands, snapshot); // For each entity in the snapshot for (entity_key, entity_data) in &snapshot.game_data { // Create a new entity let entity = commands.spawn_empty().id(); // Deserialize and add components deserialize_entity_components(commands, entity, entity_data); } } fn deserialize_entity_components( commands: &mut Commands, entity: Entity, entity_data: &[u8], ) { let mut offset = 8; // Skip the entity ID // While there's more data to read while offset < entity_data.len() { // Read the component ID let component_id_bytes = &entity_data[offset..offset+8]; let component_id = u64::from_le_bytes(component_id_bytes.try_into().unwrap()); offset += 8; // Read the component size let size_bytes = &entity_data[offset..offset+8]; let size = usize::from_le_bytes(size_bytes.try_into().unwrap()); offset += 8; // Read the component data let component_data = &entity_data[offset..offset+size]; offset += size; // Deserialize and add the component add_component_from_bytes(commands, entity, component_id, component_data); } } }
Snapshot Storage
The system supports saving snapshots to disk and loading them later:
#![allow(unused)] fn main() { fn save_snapshot_to_disk( snapshot: &GameSnapshot, path: &str, ) -> Result<(), std::io::Error> { // Serialize the snapshot let serialized = bincode::serialize(snapshot) .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; // Compress if configured let final_data = if snapshot.use_compression { // Apply compression let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); encoder.write_all(&serialized)?; encoder.finish()? } else { serialized }; // Write to file std::fs::write(path, final_data)?; Ok(()) } fn load_snapshot_from_disk( path: &str, ) -> Result<GameSnapshot, std::io::Error> { // Read file let data = std::fs::read(path)?; // Check for compression let serialized = if is_compressed(&data) { // Decompress let mut decoder = flate2::read::GzDecoder::new(&data[..]); let mut decompressed = Vec::new(); decoder.read_to_end(&mut decompressed)?; decompressed } else { data }; // Deserialize let snapshot = bincode::deserialize(&serialized) .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; Ok(snapshot) } }
Performance Considerations
The snapshot system is designed with performance in mind:
- Selective Serialization: Only necessary components are included
- Batched Processing: Limits how many snapshots are processed per frame
- Compression Options: Configurable to balance size vs. speed
- Marker Components: Only entities explicitly marked are included
- Queue Management: Background processing to minimize frame time impact
Integration Points
The snapshot system integrates with other systems through:
- Events: For triggering and receiving snapshot operations
- Component Markers: To specify what entities should be included
- Configuration: To control behavior based on game requirements
- Plugins: To integrate with other game systems
These integration points provide flexibility while maintaining clean separation of concerns.
Next Steps
- Integration with Networking: How snapshots work with the networking system
- Testing: How to test snapshot functionality
- API Reference: Complete reference documentation
Snapshot Integration with Networking
This document explains how the snapshot system integrates with Rummage's networking capabilities to enable multiplayer gameplay.
Overview
The snapshot system forms a critical part of Rummage's networking architecture, providing:
- State Synchronization: Ensuring all clients have the same game state
- Rollback Capability: Allowing recovery from network disruptions
- Deterministic Execution: Working with deterministic systems for consistent gameplay
- Hidden Information Management: Handling information that should be hidden from certain players
Integration Architecture
Core Components
The networking integration uses these key components:
#![allow(unused)] fn main() { /// Plugin that integrates the snapshot system with networking pub struct NetworkSnapshotPlugin; /// Resource that tracks network-specific snapshot configuration #[derive(Resource)] pub struct NetworkSnapshotConfig { /// Frequency of network snapshot updates (in seconds) pub sync_frequency: f32, /// Maximum size of a snapshot packet (in bytes) pub max_packet_size: usize, /// Whether to compress network snapshots pub compress_network_snapshots: bool, /// Number of snapshots to keep for rollback purposes pub rollback_history_size: usize, } /// Component marking entities with network-specific snapshot requirements #[derive(Component)] pub struct NetworkSnapshotable { /// Which player IDs can see this entity pub visible_to: Vec<u64>, /// Priority for synchronization (higher values sync first) pub sync_priority: u8, } }
Plugin Implementation
The integration plugin adds networking-specific systems:
#![allow(unused)] fn main() { impl Plugin for NetworkSnapshotPlugin { fn build(&self, app: &mut App) { app // Register resources .init_resource::<NetworkSnapshotConfig>() .init_resource::<PendingNetworkSnapshots>() // Register custom events .add_event::<NetworkSnapshotEvent>() // Add networking-specific systems .add_systems(Update, ( sync_game_state_with_clients, handle_network_snapshot_events, process_incoming_snapshots, ).chain()) // Add systems to the replicon client and server sets .add_systems(RepliconClientSet::Receive, receive_network_snapshot) .add_systems(RepliconServerSet::Send, send_network_snapshots); } } }
State Synchronization
The core of the networking integration is the state synchronization system:
#![allow(unused)] fn main() { fn sync_game_state_with_clients( mut commands: Commands, time: Res<Time>, mut last_sync: Local<f32>, config: Res<NetworkSnapshotConfig>, game_state: Res<GameState>, client_info: Res<ClientRegistry>, world: &World, mut network_events: EventWriter<NetworkSnapshotEvent>, ) { // Check if it's time for a sync if time.elapsed_seconds() - *last_sync < config.sync_frequency { return; } *last_sync = time.elapsed_seconds(); // Create a base snapshot of the current state let base_snapshot = create_game_snapshot(world, &game_state); // For each connected client, create a tailored snapshot for client_id in client_info.connected_clients() { let client_snapshot = create_client_specific_snapshot( &base_snapshot, client_id, &client_info ); // Send the snapshot to the client network_events.send(NetworkSnapshotEvent::SendTo( client_id, client_snapshot )); } } }
Client-Specific Snapshots
The system creates tailored snapshots for each client to manage hidden information:
#![allow(unused)] fn main() { fn create_client_specific_snapshot( base_snapshot: &GameSnapshot, client_id: u64, client_info: &ClientRegistry, ) -> GameSnapshot { // Clone the base snapshot let mut client_snapshot = base_snapshot.clone(); // Filter out data the client shouldn't see client_snapshot.game_data.retain(|entity_key, _| { // Check if this entity is visible to the client let entity = Entity::from_bits( u64::from_str(entity_key).unwrap_or_default() ); is_entity_visible_to_client(entity, client_id, client_info) }); // Modify certain data to hide information for (_, entity_data) in client_snapshot.game_data.iter_mut() { sanitize_hidden_information(entity_data, client_id, client_info); } client_snapshot } fn is_entity_visible_to_client( entity: Entity, client_id: u64, client_info: &ClientRegistry, ) -> bool { // Logic to determine if an entity should be visible to a client // ... } fn sanitize_hidden_information( entity_data: &mut Vec<u8>, client_id: u64, client_info: &ClientRegistry, ) { // Logic to modify entity data to hide sensitive information // ... } }
Network Data Transfer
The system handles sending and receiving snapshots over the network:
#![allow(unused)] fn main() { fn send_network_snapshots( mut server: ResMut<RenetServer>, mut pending: ResMut<PendingNetworkSnapshots>, config: Res<NetworkSnapshotConfig>, ) { // Process all pending network snapshots for (client_id, snapshot) in pending.outgoing.drain(..) { // Serialize the snapshot let serialized = bincode::serialize(&snapshot) .unwrap_or_default(); // Compress if configured let final_data = if config.compress_network_snapshots { // Compression logic... Vec::new() } else { serialized }; // Send to client server.send_message( client_id, NetworkChannel::StateSync as u8, final_data ); } } fn receive_network_snapshot( mut client: ResMut<RenetClient>, mut snapshot_events: EventWriter<SnapshotEvent>, ) { // Check for incoming snapshot messages while let Some(message) = client.receive_message(NetworkChannel::StateSync as u8) { // Decompress if needed let serialized = if is_compressed(&message) { // Decompression logic... Vec::new() } else { message }; // Deserialize the snapshot if let Ok(snapshot) = bincode::deserialize::<GameSnapshot>(&serialized) { // Apply the received snapshot snapshot_events.send(SnapshotEvent::Apply(snapshot.id)); } } } }
Rollback System
The integration includes a rollback system for handling network disruptions:
/// Plugin that provides rollback functionality for network gameplay pub struct RollbackPlugin; impl Plugin for RollbackPlugin { fn build(&self, app: &mut App) { app .init_resource::<RollbackHistory>() .add_event::<RollbackEvent>() .add_systems(Update, ( maintain_rollback_history, handle_rollback_events, ).chain()); } } /// Resource that maintains a history of snapshots for rollback #[derive(Resource, Default)] pub struct RollbackHistory { /// Historical snapshots indexed by turn and phase pub history: HashMap<(u32, Phase), GameSnapshot>, } /// Event requesting a rollback to a previous state #[derive(Event)] pub struct RollbackEvent { /// The turn to roll back to pub turn: u32, /// The phase within the turn pub phase: Option<Phase>, } fn maintain_rollback_history( mut history: ResMut<RollbackHistory>, mut snapshot_events: EventReader<SnapshotProcessedEvent>, snapshots: Res<SnapshotRegistry>, config: Res<NetworkSnapshotConfig>, ) { // For each new snapshot, add it to the history for event in snapshot_events.iter() { if let Some(snapshot) = snapshots.get(event.id) { history.history.insert( (snapshot.turn, snapshot.phase.clone()), snapshot.clone() ); } } // Prune history to maintain size limits if history.history.len() > config.rollback_history_size { // Pruning logic... } } fn handle_rollback_events( mut rollback_events: EventReader<RollbackEvent>, history: Res<RollbackHistory>, mut snapshot_events: EventWriter<SnapshotEvent>, ) { for event in rollback_events.iter() { // Find the snapshot to roll back to let target_snapshot = if let Some(phase) = &event.phase { // Find specific phase history.history.get(&(event.turn, phase.clone())) } else { // Find any snapshot from this turn history.history.iter() .find(|((turn, _), _)| *turn == event.turn) .map(|(_, snapshot)| snapshot) }; // If found, apply the snapshot if let Some(snapshot) = target_snapshot { snapshot_events.send(SnapshotEvent::Apply(snapshot.id)); } } }
Deterministic Random Number Generator
The integration includes special handling for random number generation to ensure deterministic gameplay:
#![allow(unused)] fn main() { /// Plugin that integrates deterministic RNG with snapshots for networking pub struct DeterministicRNGPlugin; impl Plugin for DeterministicRNGPlugin { fn build(&self, app: &mut App) { app .init_resource::<NetworkedRngState>() .add_systems(Update, ( capture_rng_in_snapshot, restore_rng_from_snapshot, ).chain()); } } /// Resource that tracks the state of the deterministic RNG #[derive(Resource, Serialize, Deserialize)] pub struct NetworkedRngState { /// The current seed pub seed: u64, /// The number of times the RNG has been used pub usage_count: u64, } fn capture_rng_in_snapshot( rng_state: Res<NetworkedRngState>, mut create_snapshot: EventReader<SnapshotEvent>, mut snapshots: ResMut<SnapshotRegistry>, ) { for event in create_snapshot.iter() { if let SnapshotEvent::Take = event { // Find the most recent snapshot if let Some(snapshot) = snapshots.most_recent() { // Add RNG state to the snapshot let rng_data = bincode::serialize(&*rng_state).unwrap_or_default(); snapshot.game_data.insert("rng_state".to_string(), rng_data); } } } } fn restore_rng_from_snapshot( mut rng_state: ResMut<NetworkedRngState>, mut apply_snapshot: EventReader<SnapshotEvent>, snapshots: Res<SnapshotRegistry>, ) { for event in apply_snapshot.iter() { if let SnapshotEvent::Apply(id) = event { // Find the snapshot if let Some(snapshot) = snapshots.get(*id) { // Restore RNG state from snapshot if let Some(rng_data) = snapshot.game_data.get("rng_state") { if let Ok(state) = bincode::deserialize::<NetworkedRngState>(rng_data) { *rng_state = state; } } } } } } }
Testing Network Integration
Testing the networking integration with snapshots:
#![allow(unused)] fn main() { #[test] fn test_network_snapshot_synchronization() { // Set up a test server and client let mut app = App::new(); app.add_plugins(( MinimalPlugins, RepliconServerPlugin, SnapshotPlugin, NetworkSnapshotPlugin, )); // Create a test client let client_id = 1; let mut client_registry = ClientRegistry::default(); client_registry.register_client(client_id); app.insert_resource(client_registry); // Set up game state setup_test_game_state(&mut app); // Trigger a snapshot app.world.send_event(SnapshotEvent::Take); app.update(); // Verify that a network snapshot was created let network_events = app.world.resource::<Events<NetworkSnapshotEvent>>(); let mut reader = network_events.get_reader(); let has_snapshot_event = reader.iter(&network_events).any(|event| { matches!(event, NetworkSnapshotEvent::SendTo(id, _) if *id == client_id) }); assert!(has_snapshot_event, "Should create a network snapshot for the client"); } }
Best Practices
When working with the snapshot and networking integration:
- Minimize Snapshot Size: Use the
NetworkSnapshotable
component to control what gets synchronized - Handle Frequent Updates: Be mindful of performance impact for frequently changing components
- Test Network Conditions: Use simulated network conditions to test behavior under varying latency
- Secure Hidden Information: Carefully audit what information is sent to each client
- Handle Reconnections: Ensure clients that reconnect receive a complete state update
- Monitor Bandwidth: Keep track of snapshot sizes and network usage
- Implement Fallbacks: Have strategies for when snapshot synchronization fails
Next Steps
- Testing: How to test snapshot functionality, including network integration
- API Reference: Complete reference documentation for the snapshot system
State Persistence with bevy_persistent
This document details how to use bevy_persistent
for robust game state persistence and rollback management in Rummage.
Overview
Game state persistence and rollback functionality are critical for:
- Save/Load: Allowing players to save and resume their games
- Undo Support: Enabling players to revert mistakes
- Checkpoints: Creating automatic save points at important moments
- Crash Recovery: Recovering from crashes without losing progress
- Replay: Supporting replay functionality
bevy_persistent
provides an elegant solution for these requirements by automatically handling serialization, deserialization, and file I/O.
Integration with Snapshot System
The snapshot system can be enhanced with bevy_persistent
to provide automatic, reliable state persistence:
#![allow(unused)] fn main() { use bevy::prelude::*; use bevy_persistent::prelude::*; use serde::{Deserialize, Serialize}; /// A serializable snapshot of game state #[derive(Resource, Serialize, Deserialize, Clone, Debug)] pub struct GameSnapshot { /// Unique identifier for the snapshot pub id: Uuid, /// The game turn the snapshot was taken on pub turn: u32, /// The phase within the turn pub phase: Phase, /// Active player when snapshot was taken pub active_player: usize, /// Serialized game entities pub entities: Vec<SerializedEntity>, /// Timestamp when the snapshot was created pub timestamp: f64, } /// Collection of game snapshots for rollback support #[derive(Resource, Serialize, Deserialize, Default)] pub struct GameSnapshotCollection { /// Map of snapshot ID to snapshot data pub snapshots: HashMap<Uuid, GameSnapshot>, /// Order of snapshots (earliest to latest) pub history: Vec<Uuid>, /// Maximum number of snapshots to keep #[serde(skip)] pub max_snapshots: usize, /// Current snapshot ID (the active state) pub current_snapshot_id: Option<Uuid>, } // Set up persistent snapshot system in plugin fn build_persistent_snapshots(app: &mut App) { // Create persistent snapshot collection let persistent_snapshots = Persistent::<GameSnapshotCollection>::builder() .name("game_snapshots") .format(StorageFormat::Bincode) // More efficient for large state .path("user://snapshots.bin") .default(GameSnapshotCollection { snapshots: HashMap::new(), history: Vec::new(), max_snapshots: 50, // Keep last 50 snapshots current_snapshot_id: None, }) .build(); app.insert_resource(persistent_snapshots) .add_systems(Update, auto_save_snapshots) .add_systems(Startup, load_snapshot_history); } }
Creating Snapshots
The system for creating snapshots integrates with bevy_persistent
:
#![allow(unused)] fn main() { /// Create a new game state snapshot fn create_snapshot( world: &mut World, mut snapshots: ResMut<Persistent<GameSnapshotCollection>>, ) -> Uuid { // Get relevant game state information let turn = world.resource::<TurnManager>().current_turn; let phase = *world.resource::<Phase>(); let active_player = world.resource::<TurnManager>().active_player; // Create snapshot ID let id = Uuid::new_v4(); // Serialize entities let entities = serialize_game_entities(world); // Create snapshot let snapshot = GameSnapshot { id, turn, phase, active_player, entities, timestamp: world.resource::<Time>().elapsed_seconds_f64(), }; // Add to collection snapshots.snapshots.insert(id, snapshot); snapshots.history.push(id); snapshots.current_snapshot_id = Some(id); // Prune old snapshots if needed prune_old_snapshots(&mut snapshots); // Trigger save if let Err(err) = snapshots.save() { error!("Failed to save game snapshot: {}", err); } id } }
Loading and Applying Snapshots
To restore a game state from a snapshot:
#![allow(unused)] fn main() { /// Load a specific snapshot by ID fn load_snapshot( world: &mut World, snapshot_id: Uuid, ) -> Result<(), String> { // Get snapshot collection let snapshots = world.resource::<Persistent<GameSnapshotCollection>>(); // Find the snapshot let snapshot = match snapshots.snapshots.get(&snapshot_id) { Some(snapshot) => snapshot, None => return Err(format!("Snapshot with ID {} not found", snapshot_id)), }; // Apply the snapshot to the world apply_snapshot_to_world(world, snapshot)?; // Update current snapshot ID let mut snapshots = world.resource_mut::<Persistent<GameSnapshotCollection>>(); snapshots.current_snapshot_id = Some(snapshot_id); info!("Loaded snapshot from turn {} (ID: {})", snapshot.turn, snapshot_id); Ok(()) } /// Apply a snapshot to the world fn apply_snapshot_to_world( world: &mut World, snapshot: &GameSnapshot, ) -> Result<(), String> { // First, clean up existing entities that should be replaced let entities_to_despawn = get_entities_to_despawn(world); for entity in entities_to_despawn { world.despawn(entity); } // Restore serialized entities for serialized_entity in &snapshot.entities { deserialize_and_spawn_entity(world, serialized_entity)?; } // Restore global resources let mut turn_manager = world.resource_mut::<TurnManager>(); turn_manager.current_turn = snapshot.turn; turn_manager.active_player = snapshot.active_player; let mut phase = world.resource_mut::<Phase>(); *phase = snapshot.phase; Ok(()) } }
Automatic Checkpoint System
Game state can be automatically saved at key moments:
#![allow(unused)] fn main() { /// Create checkpoints at key moments in the game fn checkpoint_system( mut commands: Commands, world: &mut World, mut snapshots: ResMut<Persistent<GameSnapshotCollection>>, turn_events: EventReader<TurnStartEvent>, ) { // Create checkpoints at the start of each turn for event in turn_events.iter() { info!("Creating checkpoint at start of turn {}", event.turn); let snapshot_id = create_snapshot(world, snapshots.reborrow()); info!("Created checkpoint with ID: {}", snapshot_id); } } }
Rollback System
The rollback system allows reverting to previous states:
#![allow(unused)] fn main() { /// Roll back to a previous state fn rollback_system( mut commands: Commands, mut world: &mut World, rollback_events: EventReader<RollbackEvent>, mut snapshots: ResMut<Persistent<GameSnapshotCollection>>, ) { for event in rollback_events.iter() { match event { RollbackEvent::ToTurn(turn) => { // Find the snapshot for this turn if let Some(snapshot_id) = find_snapshot_for_turn(*turn, &snapshots) { if let Err(err) = load_snapshot(world, snapshot_id) { error!("Failed to roll back to turn {}: {}", turn, err); } else { info!("Successfully rolled back to turn {}", turn); } } else { error!("No snapshot found for turn {}", turn); } }, RollbackEvent::ToPreviousTurn => { // Roll back one turn if let Some(current_id) = snapshots.current_snapshot_id { if let Some(prev_id) = get_previous_snapshot_id(current_id, &snapshots) { if let Err(err) = load_snapshot(world, prev_id) { error!("Failed to roll back to previous turn: {}", err); } else { info!("Successfully rolled back to previous turn"); } } else { error!("No previous snapshot found"); } } }, // Other rollback types... } } } }
Save/Load Game Interface
A user interface for saving and loading games:
#![allow(unused)] fn main() { /// Save the current game with a custom name fn save_game( world: &mut World, name: &str, ) -> Result<(), String> { // Create a snapshot of the current state let snapshots = world.resource_mut::<Persistent<GameSnapshotCollection>>(); let snapshot_id = create_snapshot(world, snapshots); // Create a named save file let save_data = Persistent::<NamedGameSave>::builder() .name(name) .format(StorageFormat::Bincode) .path(format!("user://saves/{}.save", name)) .default(NamedGameSave { name: name.to_string(), snapshot_id, created_at: chrono::Utc::now(), game_info: extract_game_info(world), }) .build(); // Save the file if let Err(err) = save_data.save() { return Err(format!("Failed to save game: {}", err)); } info!("Game saved successfully as '{}'", name); Ok(()) } /// Load a saved game by name fn load_game( world: &mut World, name: &str, ) -> Result<(), String> { // Load the save file let save_data = Persistent::<NamedGameSave>::builder() .name(name) .format(StorageFormat::Bincode) .path(format!("user://saves/{}.save", name)) .build(); if let Err(err) = save_data.load() { return Err(format!("Failed to load save file '{}': {}", name, err)); } // Load the snapshot load_snapshot(world, save_data.snapshot_id) } }
Crash Recovery
Automatic recovery from crashes using persistent snapshots:
#![allow(unused)] fn main() { /// System to check for and recover from crashes fn crash_recovery_system( world: &mut World, mut snapshots: ResMut<Persistent<GameSnapshotCollection>>, ) { // Check for crash indicator file if std::path::Path::new("user://crash_indicator").exists() { warn!("Detected previous crash, attempting recovery"); // Try to load snapshots if let Err(err) = snapshots.load() { error!("Failed to load snapshots during crash recovery: {}", err); return; } // Find the most recent valid snapshot if let Some(latest_id) = snapshots.history.last().copied() { info!("Recovering from snapshot {}", latest_id); if let Err(err) = load_snapshot(world, latest_id) { error!("Failed to recover from crash: {}", err); } else { info!("Successfully recovered from crash"); } } else { error!("No snapshots available for crash recovery"); } // Remove crash indicator if let Err(err) = std::fs::remove_file("user://crash_indicator") { error!("Failed to remove crash indicator: {}", err); } } // Create crash indicator file if let Err(err) = std::fs::write("user://crash_indicator", "1") { error!("Failed to create crash indicator: {}", err); } } }
Hot Reload Support
bevy_persistent
supports hot reloading for development and testing:
#![allow(unused)] fn main() { /// Enable hot reloading of snapshots during development fn setup_hot_reload( app: &mut App, snapshots: Persistent<GameSnapshotCollection>, ) { #[cfg(debug_assertions)] { let snapshot_path = snapshots.path().unwrap().to_path_buf(); app.add_systems(Update, move |world: &mut World| { // Check if the file has been modified if snapshot_path_modified(&snapshot_path) { info!("Detected external changes to snapshot file, reloading"); let mut snapshots = world.resource_mut::<Persistent<GameSnapshotCollection>>(); if let Err(err) = snapshots.load() { error!("Failed to hot reload snapshots: {}", err); } else { info!("Successfully hot reloaded snapshots"); } } }); } } }
Benefits of bevy_persistent for State Management
Using bevy_persistent
for state management offers several key advantages:
- Atomicity: Save operations are atomic, reducing the risk of corruption
- Error Handling: Comprehensive error handling for all I/O operations
- Versioning: Support for schema versioning when state structure changes
- Format Flexibility: Support for multiple serialization formats
- Hot Reloading: Ability to detect and reload changes at runtime
- Cross-Platform: Works consistently across all supported platforms
- Performance: Efficient serialization with bincode for large states
Related Documentation
- Snapshot Overview: Introduction to the snapshot system
- Implementation: Technical details of snapshot implementation
- Deck Persistence: Using bevy_persistent for deck storage
Snapshot System Testing
This document covers testing approaches and strategies for the Rummage snapshot system. For a general overview of testing in Rummage, see the Testing Overview.
Types of Tests
The snapshot system should be tested at several levels:
- Unit Tests: Isolated tests of individual snapshot components and functions
- Integration Tests: Tests of snapshot system interaction with other game systems
- End-to-End Tests: Tests of complete game scenarios using snapshots
- Performance Tests: Tests of snapshot system performance characteristics
- Network Tests: Tests of snapshot integration with networking
Unit Testing
Unit tests focus on individual components of the snapshot system:
#![allow(unused)] fn main() { #[test] fn test_snapshot_creation() { // Set up a minimal app let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(SnapshotPlugin); // Add some test entities let entity1 = app.world.spawn((Snapshotable, TestComponent { value: 42 })).id(); let entity2 = app.world.spawn((Snapshotable, TestComponent { value: 123 })).id(); // Create a game state let game_state = GameState { turn: 1, phase: Phase::Main1, active_player: 0, }; app.insert_resource(game_state); // Create a snapshot app.world.send_event(SnapshotEvent::Take); app.update(); // Verify the snapshot was created let snapshot_registry = app.world.resource::<SnapshotRegistry>(); assert_eq!(snapshot_registry.snapshots.len(), 1, "Should create one snapshot"); // Verify the snapshot contents let snapshot = snapshot_registry.most_recent().unwrap(); assert_eq!(snapshot.turn, 1, "Snapshot should have the correct turn"); assert_eq!(snapshot.phase, Phase::Main1, "Snapshot should have the correct phase"); assert_eq!(snapshot.active_player, 0, "Snapshot should have the correct active player"); // Verify entities were captured assert_eq!(snapshot.game_data.len(), 2, "Snapshot should include 2 entities"); } #[test] fn test_snapshot_application() { // Set up a minimal app let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(SnapshotPlugin); // Add some test entities let entity1 = app.world.spawn((Snapshotable, TestComponent { value: 42 })).id(); // Create a game state let game_state = GameState { turn: 1, phase: Phase::Main1, active_player: 0, }; app.insert_resource(game_state); // Create a snapshot app.world.send_event(SnapshotEvent::Take); app.update(); // Get the snapshot ID let snapshot_id = app.world.resource::<SnapshotRegistry>() .most_recent().unwrap().id; // Modify the entity if let Some(mut test_comp) = app.world.get_mut::<TestComponent>(entity1) { test_comp.value = 99; } // Apply the snapshot app.world.send_event(SnapshotEvent::Apply(snapshot_id)); app.update(); // Verify the entity was restored to its original state let test_comp = app.world.get::<TestComponent>(entity1).unwrap(); assert_eq!(test_comp.value, 42, "Component should be restored to original value"); } }
Integration Testing
Integration tests verify how snapshots interact with other game systems:
#![allow(unused)] fn main() { #[test] fn test_snapshot_with_turn_system() { // Set up a test app with relevant plugins let mut app = App::new(); app.add_plugins(( MinimalPlugins, SnapshotPlugin, TurnSystemPlugin, )); // Configure auto-snapshots let mut config = SnapshotConfig::default(); config.auto_snapshot_on_turn = true; app.insert_resource(config); // Set up initial game state app.insert_resource(GameState { turn: 1, phase: Phase::Main1, active_player: 0, }); // Add some test entities app.world.spawn((Snapshotable, TestComponent { value: 42 })); // Advance the turn app.world.send_event(AdvanceTurnEvent); app.update(); // Verify a snapshot was automatically created let snapshot_registry = app.world.resource::<SnapshotRegistry>(); assert_eq!(snapshot_registry.snapshots.len(), 1, "Should create one snapshot on turn change"); // Verify the snapshot has the correct turn number let snapshot = snapshot_registry.most_recent().unwrap(); assert_eq!(snapshot.turn, 2, "Snapshot should capture the new turn number"); } }
End-to-End Testing
End-to-end tests verify complete game scenarios:
#![allow(unused)] fn main() { #[test] fn test_full_game_with_snapshots() { // Set up a complete game environment let mut app = App::new(); app.add_plugins(( DefaultPlugins, GameEnginePlugin, SnapshotPlugin, )); // Set up a test game setup_test_game(&mut app); // Play through multiple turns, taking snapshots for turn in 1..5 { // Play through a turn play_turn(&mut app); // Take a snapshot app.world.send_event(SnapshotEvent::Take); app.update(); } // Verify we have snapshots for each turn let snapshot_registry = app.world.resource::<SnapshotRegistry>(); assert_eq!(snapshot_registry.snapshots.len(), 4, "Should have 4 snapshots"); // Go back to turn 2 let turn_2_snapshot = snapshot_registry.snapshots.values() .find(|s| s.turn == 2) .unwrap(); app.world.send_event(SnapshotEvent::Apply(turn_2_snapshot.id)); app.update(); // Verify game state was restored correctly let game_state = app.world.resource::<GameState>(); assert_eq!(game_state.turn, 2, "Game should be restored to turn 2"); // Continue playing from this restored state play_turn(&mut app); // Verify the game progressed correctly from the restored state let game_state = app.world.resource::<GameState>(); assert_eq!(game_state.turn, 3, "Game should advance to turn 3"); } }
Performance Testing
Performance tests measure the impact of snapshots:
#![allow(unused)] fn main() { use criterion::{black_box, criterion_group, criterion_main, Criterion}; fn snapshot_creation_benchmark(c: &mut Criterion) { c.bench_function("create snapshot with 100 entities", |b| { // Set up a test app let mut app = App::new(); app.add_plugins((MinimalPlugins, SnapshotPlugin)); // Add 100 test entities for i in 0..100 { app.world.spawn((Snapshotable, TestComponent { value: i })); } b.iter(|| { // Create a snapshot app.world.send_event(SnapshotEvent::Take); app.update(); // Clear the snapshots for the next iteration app.world.resource_mut::<SnapshotRegistry>().snapshots.clear(); }); }); } fn snapshot_application_benchmark(c: &mut Criterion) { c.bench_function("apply snapshot with 100 entities", |b| { // Set up a test app let mut app = App::new(); app.add_plugins((MinimalPlugins, SnapshotPlugin)); // Add 100 test entities for i in 0..100 { app.world.spawn((Snapshotable, TestComponent { value: i })); } // Create a snapshot app.world.send_event(SnapshotEvent::Take); app.update(); // Get the snapshot ID let snapshot_id = app.world.resource::<SnapshotRegistry>() .most_recent().unwrap().id; b.iter(|| { // Apply the snapshot app.world.send_event(SnapshotEvent::Apply(snapshot_id)); app.update(); }); }); } criterion_group!( snapshot_benches, snapshot_creation_benchmark, snapshot_application_benchmark ); criterion_main!(snapshot_benches); }
Network Testing
Testing snapshot integration with networking:
#![allow(unused)] fn main() { #[test] fn test_network_snapshot_sync() { // Set up a server app let mut server_app = App::new(); server_app.add_plugins(( MinimalPlugins, RepliconServerPlugin, SnapshotPlugin, NetworkSnapshotPlugin, )); // Set up a client app let mut client_app = App::new(); client_app.add_plugins(( MinimalPlugins, RepliconClientPlugin, SnapshotPlugin, NetworkSnapshotPlugin, )); // Connect the client to the server let client_id = connect_client_to_server(&mut server_app, &mut client_app); // Set up game state on the server setup_test_game(&mut server_app); // Trigger a snapshot and network sync server_app.world.send_event(SnapshotEvent::Take); server_app.update(); // Run multiple updates to allow for network processing for _ in 0..10 { server_app.update(); client_app.update(); } // Verify the client received and applied the snapshot let client_state = client_app.world.resource::<GameState>(); let server_state = server_app.world.resource::<GameState>(); assert_eq!(client_state.turn, server_state.turn, "Client turn should match server"); assert_eq!(client_state.phase, server_state.phase, "Client phase should match server"); } }
Testing Deterministic RNG
Tests for RNG integration with snapshots:
#![allow(unused)] fn main() { #[test] fn test_rng_snapshot_determinism() { // Set up a test app let mut app = App::new(); app.add_plugins(( MinimalPlugins, SnapshotPlugin, DeterministicRNGPlugin, )); // Initialize RNG with a known seed app.insert_resource(NetworkedRngState { seed: 12345, usage_count: 0, }); // Create an RNG and generate some random numbers let mut rng = app.world.resource_mut::<NetworkedRngState>().create_rng(); let first_values: Vec<u32> = (0..10).map(|_| rng.gen::<u32>()).collect(); // Take a snapshot app.world.send_event(SnapshotEvent::Take); app.update(); // Get the snapshot ID let snapshot_id = app.world.resource::<SnapshotRegistry>() .most_recent().unwrap().id; // Generate more random numbers (changing the RNG state) let mut rng = app.world.resource_mut::<NetworkedRngState>().create_rng(); for _ in 0..20 { rng.gen::<u32>(); } // Apply the snapshot to restore the RNG state app.world.send_event(SnapshotEvent::Apply(snapshot_id)); app.update(); // Generate random numbers again from the restored state let mut rng = app.world.resource_mut::<NetworkedRngState>().create_rng(); let restored_values: Vec<u32> = (0..10).map(|_| rng.gen::<u32>()).collect(); // Verify the sequences are identical assert_eq!(first_values, restored_values, "RNG sequences should be identical after snapshot restoration"); } }
Test Fixtures
Creating reusable test fixtures:
#![allow(unused)] fn main() { /// Sets up a basic test environment for snapshot testing fn setup_snapshot_test_environment() -> App { let mut app = App::new(); app.add_plugins(( MinimalPlugins, SnapshotPlugin, )); // Add test entities app.world.spawn((Snapshotable, TestComponent { value: 1 })); app.world.spawn((Snapshotable, TestComponent { value: 2 })); app.world.spawn((Snapshotable, TestComponent { value: 3 })); // Add game state app.insert_resource(GameState { turn: 1, phase: Phase::Main1, active_player: 0, }); app } /// Creates a snapshot and returns the snapshot ID fn create_test_snapshot(app: &mut App) -> Uuid { app.world.send_event(SnapshotEvent::Take); app.update(); app.world.resource::<SnapshotRegistry>() .most_recent() .unwrap() .id } /// Test component for snapshot tests #[derive(Component, Clone, PartialEq, Debug, Serialize, Deserialize)] struct TestComponent { value: i32, } }
Mocking Dependencies
Using mocks for testing:
#![allow(unused)] fn main() { /// Mock game state for testing #[derive(Resource, Clone, Debug, Default)] struct MockGameState { turn: u32, phase: Phase, active_player: usize, } /// Mock event for testing auto-snapshots #[derive(Event)] struct MockTurnChangeEvent; /// Test that snapshots are triggered by events using mocks #[test] fn test_snapshot_event_triggers() { // Set up a test app let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugin(SnapshotPlugin) .add_event::<MockTurnChangeEvent>(); // Add a system that listens for MockTurnChangeEvent and triggers snapshots app.add_systems(Update, | mut turn_events: EventReader<MockTurnChangeEvent>, mut snapshot_events: EventWriter<SnapshotEvent>, | { for _ in turn_events.iter() { snapshot_events.send(SnapshotEvent::Take); } }); // Send a mock turn change event app.world.send_event(MockTurnChangeEvent); app.update(); // Verify a snapshot was created let snapshot_registry = app.world.resource::<SnapshotRegistry>(); assert_eq!(snapshot_registry.snapshots.len(), 1, "Should create a snapshot in response to the event"); } }
Best Practices
When testing the snapshot system:
- Isolate Tests: Each test should focus on a specific aspect of the snapshot system
- Use Test Fixtures: Create reusable setups for snapshot testing
- Test Error Handling: Verify behavior when snapshots fail or are corrupted
- Measure Performance: Track performance metrics for snapshot operations
- Test Edge Cases:
- Empty snapshots
- Very large snapshots
- Concurrent snapshot operations
- Snapshot application during state changes
- Test Integration Points: Verify all systems that interact with snapshots
- Use Mocks: Create mock implementations of dependencies for focused testing
Next Steps
- API Reference: Complete reference documentation for the snapshot system
Snapshot System API Reference
This document provides a comprehensive reference for the Snapshot System API in Rummage.
Core Types
GameSnapshot
The primary data structure for storing game state:
#![allow(unused)] fn main() { /// A serializable snapshot of the game state #[derive(Serialize, Deserialize, Clone, Debug)] pub struct GameSnapshot { /// Unique identifier for the snapshot pub id: Uuid, /// The game turn the snapshot was taken on pub turn: u32, /// The phase within the turn pub phase: Phase, /// Active player when snapshot was taken pub active_player: usize, /// Serialized game data pub game_data: HashMap<String, Vec<u8>>, /// Timestamp when the snapshot was created pub timestamp: f64, } impl GameSnapshot { /// Creates a new empty snapshot pub fn new(turn: u32, phase: Phase, active_player: usize) -> Self; /// Returns the size of the snapshot in bytes pub fn size_bytes(&self) -> usize; /// Checks if the snapshot contains a specific entity pub fn contains_entity(&self, entity: Entity) -> bool; /// Gets the serialized data for a specific entity pub fn get_entity_data(&self, entity: Entity) -> Option<&Vec<u8>>; } }
SnapshotRegistry
Resource for managing snapshots:
#![allow(unused)] fn main() { /// Manages snapshots in memory #[derive(Resource, Default)] pub struct SnapshotRegistry { /// All available snapshots indexed by ID pub snapshots: HashMap<Uuid, GameSnapshot>, } impl SnapshotRegistry { /// Returns the most recent snapshot pub fn most_recent(&self) -> Option<&GameSnapshot>; /// Returns a snapshot by ID pub fn get(&self, id: Uuid) -> Option<&GameSnapshot>; /// Adds a snapshot to the registry pub fn add(&mut self, snapshot: GameSnapshot); /// Removes a snapshot from the registry pub fn remove(&mut self, id: Uuid) -> Option<GameSnapshot>; /// Clears all snapshots pub fn clear(&mut self); /// Returns all snapshots sorted by timestamp pub fn all_sorted_by_time(&self) -> Vec<&GameSnapshot>; /// Finds snapshots for a specific turn pub fn find_by_turn(&self, turn: u32) -> Vec<&GameSnapshot>; } }
PendingSnapshots
Resource for tracking snapshot operations:
#![allow(unused)] fn main() { /// Tracks pending snapshot operations #[derive(Resource, Default)] pub struct PendingSnapshots { /// Snapshots waiting to be processed pub queue: VecDeque<GameSnapshot>, /// Whether snapshot processing is paused pub paused: bool, } impl PendingSnapshots { /// Adds a snapshot to the queue pub fn enqueue(&mut self, snapshot: GameSnapshot); /// Gets the next snapshot from the queue pub fn dequeue(&mut self) -> Option<GameSnapshot>; /// Pauses snapshot processing pub fn pause(&mut self); /// Resumes snapshot processing pub fn resume(&mut self); /// Clears the queue pub fn clear(&mut self); } }
SnapshotConfig
Configuration for the snapshot system:
#![allow(unused)] fn main() { /// Configuration for the snapshot system #[derive(Resource)] pub struct SnapshotConfig { /// Whether to automatically create snapshots on turn changes pub auto_snapshot_on_turn: bool, /// Whether to automatically create snapshots on phase changes pub auto_snapshot_on_phase: bool, /// Maximum number of snapshots to process per frame pub max_snapshots_per_frame: usize, /// Maximum number of snapshots to keep in history pub max_history_size: usize, /// Whether to compress snapshots pub use_compression: bool, } impl Default for SnapshotConfig { fn default() -> Self { Self { auto_snapshot_on_turn: true, auto_snapshot_on_phase: false, max_snapshots_per_frame: 1, max_history_size: 100, use_compression: true, } } } }
Components
Snapshotable
Marker component for entities that should be included in snapshots:
#![allow(unused)] fn main() { /// Marker for entities that should be included in snapshots #[derive(Component)] pub struct Snapshotable; }
SnapshotExcluded
Marker component for components that should not be included in snapshots:
#![allow(unused)] fn main() { /// Marker for components that should be excluded from snapshots #[derive(Component)] pub struct SnapshotExcluded; }
Events
SnapshotEvent
Events for controlling snapshot operations:
#![allow(unused)] fn main() { /// Events for snapshot operations #[derive(Event)] pub enum SnapshotEvent { /// Take a new snapshot of the current state Take, /// Apply a specific snapshot Apply(Uuid), /// Save the current snapshot to disk Save(String), /// Load a snapshot from disk Load(String), } }
SnapshotProcessedEvent
Event for snapshot processing completion:
#![allow(unused)] fn main() { /// Event fired when a snapshot has been processed #[derive(Event)] pub struct SnapshotProcessedEvent { /// The ID of the processed snapshot pub id: Uuid, /// Whether processing succeeded pub success: bool, /// Error message if processing failed pub error: Option<String>, } }
Plugin
SnapshotPlugin
Main plugin for the snapshot system:
#![allow(unused)] fn main() { pub struct SnapshotPlugin; impl Plugin for SnapshotPlugin { fn build(&self, app: &mut App) { app // Register resources .init_resource::<PendingSnapshots>() .init_resource::<SnapshotConfig>() .init_resource::<SnapshotRegistry>() // Register snapshot events .add_event::<SnapshotEvent>() .add_event::<SnapshotProcessedEvent>() // Add snapshot systems to the appropriate schedule .add_systems(Update, ( handle_snapshot_events, process_pending_snapshots, trigger_snapshot_on_turn_change, trigger_snapshot_on_phase_change, cleanup_old_snapshots, ).chain()); } } }
Systems
Handle Snapshot Events
#![allow(unused)] fn main() { /// Handles snapshot events pub fn handle_snapshot_events( mut commands: Commands, mut snapshot_events: EventReader<SnapshotEvent>, mut pending: ResMut<PendingSnapshots>, mut snapshot_registry: ResMut<SnapshotRegistry>, mut processed_events: EventWriter<SnapshotProcessedEvent>, game_state: Res<GameState>, time: Res<Time>, world: &World, ) { // Implementation details... } }
Process Pending Snapshots
#![allow(unused)] fn main() { /// Process any pending snapshots in the queue pub fn process_pending_snapshots( mut commands: Commands, mut pending: ResMut<PendingSnapshots>, mut processed_events: EventWriter<SnapshotProcessedEvent>, config: Res<SnapshotConfig>, ) { // Implementation details... } }
Automatic Snapshot Triggers
#![allow(unused)] fn main() { /// Triggers a snapshot when the turn changes pub fn trigger_snapshot_on_turn_change( mut turn_events: EventReader<TurnChangeEvent>, mut snapshot_events: EventWriter<SnapshotEvent>, config: Res<SnapshotConfig>, ) { // Implementation details... } /// Triggers a snapshot when the phase changes pub fn trigger_snapshot_on_phase_change( mut phase_events: EventReader<PhaseChangeEvent>, mut snapshot_events: EventWriter<SnapshotEvent>, config: Res<SnapshotConfig>, ) { // Implementation details... } }
Cleanup System
#![allow(unused)] fn main() { /// Cleans up old snapshots to maintain the history size limit pub fn cleanup_old_snapshots( mut snapshot_registry: ResMut<SnapshotRegistry>, config: Res<SnapshotConfig>, ) { // Implementation details... } }
Functions
Create Snapshot
#![allow(unused)] fn main() { /// Creates a complete snapshot of the current game state pub fn create_game_snapshot( world: &World, game_state: &GameState, ) -> GameSnapshot { // Implementation details... } }
Serialize Entity
#![allow(unused)] fn main() { /// Serializes an entity to a snapshot pub fn serialize_entity_to_snapshot( world: &World, entity: Entity, snapshot: &mut GameSnapshot, ) { // Implementation details... } }
Apply Snapshot
#![allow(unused)] fn main() { /// Applies a snapshot to restore game state pub fn apply_snapshot( commands: &mut Commands, snapshot: &GameSnapshot, ) { // Implementation details... } }
Save and Load
#![allow(unused)] fn main() { /// Saves a snapshot to disk pub fn save_snapshot_to_disk( snapshot: &GameSnapshot, path: &str, ) -> Result<(), std::io::Error> { // Implementation details... } /// Loads a snapshot from disk pub fn load_snapshot_from_disk( path: &str, ) -> Result<GameSnapshot, std::io::Error> { // Implementation details... } }
Networking Integration
NetworkSnapshotPlugin
#![allow(unused)] fn main() { /// Plugin that integrates the snapshot system with networking pub struct NetworkSnapshotPlugin; impl Plugin for NetworkSnapshotPlugin { fn build(&self, app: &mut App) { // Implementation details... } } }
NetworkSnapshotConfig
#![allow(unused)] fn main() { /// Resource that tracks network-specific snapshot configuration #[derive(Resource)] pub struct NetworkSnapshotConfig { /// Frequency of network snapshot updates (in seconds) pub sync_frequency: f32, /// Maximum size of a snapshot packet (in bytes) pub max_packet_size: usize, /// Whether to compress network snapshots pub compress_network_snapshots: bool, /// Number of snapshots to keep for rollback purposes pub rollback_history_size: usize, } }
NetworkSnapshotable
#![allow(unused)] fn main() { /// Component marking entities with network-specific snapshot requirements #[derive(Component)] pub struct NetworkSnapshotable { /// Which player IDs can see this entity pub visible_to: Vec<u64>, /// Priority for synchronization (higher values sync first) pub sync_priority: u8, } }
Usage Examples
Basic Usage
#![allow(unused)] fn main() { // Add the snapshot plugin to your app app.add_plugins(SnapshotPlugin); // Configure the snapshot system let mut config = SnapshotConfig::default(); config.auto_snapshot_on_turn = true; config.max_history_size = 50; app.insert_resource(config); // Mark entities for snapshot inclusion commands.spawn(( Snapshotable, MyComponent { value: 42 }, )); // Manually create a snapshot app.world.send_event(SnapshotEvent::Take); // Apply a snapshot let snapshot_id = app.world.resource::<SnapshotRegistry>() .most_recent().unwrap().id; app.world.send_event(SnapshotEvent::Apply(snapshot_id)); // Save a snapshot to disk app.world.send_event(SnapshotEvent::Save("save_game_1.snapshot".to_string())); // Load a snapshot from disk app.world.send_event(SnapshotEvent::Load("save_game_1.snapshot".to_string())); }
Network Integration
#![allow(unused)] fn main() { // Add the network snapshot plugin app.add_plugins(( SnapshotPlugin, NetworkSnapshotPlugin, )); // Configure network snapshot settings let mut network_config = NetworkSnapshotConfig::default(); network_config.sync_frequency = 0.1; // 10 updates per second network_config.compress_network_snapshots = true; app.insert_resource(network_config); // Mark entities for network visibility commands.spawn(( Snapshotable, NetworkSnapshotable { visible_to: vec![1, 2], // Only visible to players 1 and 2 sync_priority: 10, // High priority }, MyComponent { value: 42 }, )); }
Rollback Usage
#![allow(unused)] fn main() { // Add rollback plugin app.add_plugins(( SnapshotPlugin, NetworkSnapshotPlugin, RollbackPlugin, )); // Trigger a rollback to a specific turn app.world.send_event(RollbackEvent { turn: 5, phase: Some(Phase::Combat), }); }
Common Patterns
Snapshot Listener
#![allow(unused)] fn main() { fn my_snapshot_listener( mut snapshot_events: EventReader<SnapshotProcessedEvent>, ) { for event in snapshot_events.iter() { println!("Snapshot processed: {}", event.id); if !event.success { if let Some(error) = &event.error { println!("Error: {}", error); } } } } }
Custom Snapshot Trigger
#![allow(unused)] fn main() { fn trigger_snapshot_on_critical_event( mut critical_events: EventReader<CriticalGameEvent>, mut snapshot_events: EventWriter<SnapshotEvent>, ) { for event in critical_events.iter() { // Create a snapshot when critical events occur snapshot_events.send(SnapshotEvent::Take); } } }
Snapshot Analysis
#![allow(unused)] fn main() { fn analyze_snapshots( snapshot_registry: Res<SnapshotRegistry>, ) { let snapshots = snapshot_registry.all_sorted_by_time(); for snapshot in snapshots { println!("Snapshot {} - Turn {}, Phase {:?}", snapshot.id, snapshot.turn, snapshot.phase); } } }
Testing Overview
Introduction
The Rummage MTG Commander game engine employs a comprehensive testing strategy to ensure correctness, reliability, and performance across all game mechanics and user interactions.
Core Testing Principles
Our testing framework is built on these foundational principles:
Principle | Description | Implementation |
---|---|---|
Rules Correctness | All MTG rule implementations must be verified against official rules | Rule-specific test cases with expected outcomes |
Determinism | Game states must evolve consistently with the same inputs | Seeded random tests, state verification |
Cross-Platform Consistency | Behavior and visuals must be identical across platforms | Visual differential testing, behavior validation |
Performance | System must maintain responsiveness under various conditions | Load testing, benchmarking key operations |
Accessibility | Features must work with assistive technologies | Screen reader testing, keyboard navigation tests |
Testing Pyramid
We implement a comprehensive testing pyramid with increasing scope and integration:
┌─────────────┐
│ E2E & │
│ Visual │
│ Testing │
├─────────────┤
│Integration │
│ Testing │
├─────────────┤
│ Unit │
│ Testing │
└─────────────┘
Unit Testing
Unit tests verify isolated components, focusing on correctness at the smallest levels:
#![allow(unused)] fn main() { #[test] fn test_mana_cost_parsing() { // Given a mana cost string let cost_string = "{2}{W}{W}"; // When we parse it let cost = ManaCost::from_string(cost_string); // Then the components should be correctly parsed assert_eq!(cost.generic, 2); assert_eq!(cost.white, 2); assert_eq!(cost.blue, 0); assert_eq!(cost.black, 0); assert_eq!(cost.red, 0); assert_eq!(cost.green, 0); } }
Key unit test categories:
- Component Tests: Verify individual ECS components
- System Tests: Test isolated ECS systems
- Rules Tests: Verify specific rule implementations
- Parser Tests: Test card text and effect parsing
- Utility Tests: Validate helper functions
Integration Testing
Integration tests verify interactions between multiple systems:
#![allow(unused)] fn main() { #[test] fn test_creature_etb_effects() { // Create a test world with necessary systems let mut app = App::new(); app.add_plugins(TestingPlugins) .add_systems(Update, (cast_spell_system, resolve_etb_effects)); // Set up a creature card with an ETB effect let creature_entity = setup_test_creature(&mut app, "Mulldrifter"); // Cast the creature spell let player = setup_test_player(&mut app); app.world.send_event(CastSpellEvent { card: creature_entity, controller: player, targets: Vec::new(), }); // Run systems to resolve the spell app.update(); // Verify the ETB effect (draw 2 cards) occurred let player_data = app.world.get::<PlayerData>(player).unwrap(); assert_eq!(player_data.hand.len(), 2, "Player should have drawn 2 cards from ETB effect"); } }
Key integration test categories:
- Card Interactions: Test how cards affect each other
- Game State Transitions: Verify phase and turn changes
- Player Actions: Test sequences of player actions
- Zone Transitions: Validate card movement between zones
End-to-End Testing
E2E tests validate complete game scenarios from start to finish:
#![allow(unused)] fn main() { #[test] fn test_basic_commander_game() { // Initialize complete game environment let mut app = App::new(); app.add_plugins(CommanderGamePlugins); // Set up two players with predefined decks let player1 = setup_player(&mut app, "Player1", "Atraxa_Deck.json"); let player2 = setup_player(&mut app, "Player2", "Muldrotha_Deck.json"); // Define automated sequence of player actions let game_script = GameScript::from_file("test_scripts/basic_commander_game.yaml"); app.insert_resource(game_script); // Run game simulation while !app.world.resource::<GameState>().is_game_over { app.update(); } // Verify final game state let game_result = app.world.resource::<GameResult>(); assert_eq!(game_result.winner, Some(player1), "Player 1 should win this scripted game"); assert_eq!(game_result.turn_count, 12, "Game should last 12 turns"); } }
Key E2E test categories:
- Game Completion: Test full games through to completion
- Scenario Tests: Verify specific game scenarios
- Multiplayer Tests: Validate multiplayer dynamics
- Tournament Rules: Test format-specific rules
Visual Testing
Visual tests ensure consistent UI representation across platforms:
#![allow(unused)] fn main() { #[test] fn test_card_rendering() { // Initialize app with rendering plugins let mut app = App::new(); app.add_plugins(RenderingTestPlugins); // Create test card let card = setup_test_card(&mut app, "Lightning Bolt"); // Render card to texture let texture = render_card_to_texture(&mut app, card); // Compare with reference image with tolerance for slight rendering differences let reference = load_reference_image("cards/lightning_bolt.png"); let comparison = compare_images(texture, reference); assert!(comparison.similarity > 0.99, "Card rendering should match reference image"); } }
Key visual test categories:
- Card Rendering: Verify cards appear correctly
- UI Components: Test UI element appearance
- Animations: Validate animation correctness
- Layout Tests: Ensure responsive layouts work
Performance Testing
Performance tests measure system responsiveness and resource usage:
#![allow(unused)] fn main() { #[test] fn benchmark_large_board_state() { use criterion::{black_box, criterion_group, criterion_main, Criterion}; fn benchmark(c: &mut Criterion) { c.bench_function("100 card battlefield update", |b| { b.iter_batched( || setup_large_battlefield(100), // Setup 100 cards |mut app| { // Measure the time for a complete update cycle black_box(app.update()); }, criterion::BatchSize::SmallInput, ) }); } criterion_group!(benches, benchmark); criterion_main!(benches); } }
Key performance test areas:
- Frame Rate: Measure FPS under varying load
- Memory Usage: Track memory consumption
- CPU Utilization: Monitor processing requirements
- Load Scaling: Test with increasing entity counts
Snapshot Testing
Snapshot testing allows us to capture and verify game state at specific points in time, providing a powerful tool for validating complex interactions and game state transitions.
Rather than duplicating the extensive snapshot testing documentation here, please refer to the comprehensive Snapshot System Testing Documentation.
Key snapshot testing uses:
- State Verification: Validate game state correctness
- Regression Testing: Detect unintended changes to game behavior
- Cross-System Testing: Verify components work together correctly
- Replay Validation: Ensure replay system correctly reproduces game states
Network Testing
Our network testing verifies the integrity of multiplayer functionality:
- State Synchronization: Verify game states remain synchronized across clients
- Latency Simulation: Test behavior under varying network conditions
- Disconnection Handling: Validate reconnection and recovery
- Deterministic RNG: Ensure random events produce identical results across clients
Rules Compliance
Rules compliance testing verifies correct implementation of MTG rules:
- Comprehensive Rules Coverage: Tests mapped to official rules
- Judge Corner Cases: Special scenarios from tournament rulings
- Rule Interactions: Tests for complex rule interactions
- Oracle Text Tests: Validation against official card rulings
Testing Infrastructure
CI/CD Pipeline
Our continuous integration pipeline ensures ongoing quality:
-
Pull Request Checks:
- Fast unit tests run on every PR
- Linting and formatting checks
- Build verification
-
Main Branch Validation:
- Full test suite runs
- Performance regression checks
- Cross-platform test matrix
-
Release Preparation:
- Complete E2E and integration tests
- Visual regression testing
- Performance benchmarking
Test Data Management
We maintain structured test datasets:
- Card Database: Test card definitions with expected behaviors
- Game Scenarios: Predefined game states for testing
- Board States: Complex battlefield configurations
- Performance Benchmarks: Standard scenarios for consistency
Contributing Tests
To contribute new tests:
- Identify Testing Gap: Find an untested feature or edge case
- Determine Test Level: Choose appropriate test level (unit, integration, etc.)
- Write Test: Follow test pattern for that level
- Verify Coverage: Ensure test increases coverage metrics
- Submit PR: Include tests with implementation changes
See our contribution guidelines for more details.
Testing Best Practices
Follow these best practices when writing tests for Rummage:
- Test Focused Behavior: Each test should verify one specific behavior
- Use Clear Assertions: Make assertion messages descriptive
- Create Minimal Setup: Use only what's necessary for the test
- Use Test Abstractions: Share setup code between similar tests
- Test Edge Cases: Include boundary conditions and error scenarios
Testing Metrics and Goals
We track these key metrics for our test suite:
- Code Coverage: Maintain >90% coverage for core game logic
- Rules Coverage: Document percentage of MTG rules with dedicated tests
- Test Performance: Keep test suite execution time under 5 minutes
- Failure Rate: Maintain <1% flaky test ratio
Next Steps
To dive deeper into our testing approach:
- Unit Testing: Component-level testing guides
- Integration Testing: System interaction testing
- End-to-End Testing: Complete gameplay testing
- Visual Testing: UI consistency testing
- Performance Testing: System performance validation
- CI/CD Pipeline: Automated testing infrastructure
Testing Strategies
Unit Tests
Integration Tests
Visual Testing
Visual testing in Rummage allows us to detect unintended visual regressions in the game's UI and rendering. By capturing screenshots during automated tests and comparing them to reference images, we can identify changes that might break the user experience.
How Visual Testing Works
- Test Fixture Setup: Each test creates a controlled environment with known entities and camera settings
- Reference Images: The system captures screenshots and compares them against reference images
- Difference Detection: Using image comparison algorithms, the system identifies visual differences
- Artifact Generation: When differences exceed a threshold, visual diffs are generated for inspection
Running Visual Tests Locally
To run the visual tests locally, use:
cargo test --package rummage --lib "tests::visual_testing::"
Updating Reference Images
If you've made intentional visual changes, update the reference images:
GENERATE_REFERENCES=1 cargo test --package rummage --lib "tests::visual_testing::"
CI Integration
Visual tests run automatically on GitHub Actions:
- Pull Requests: Tests run to catch visual regressions
- Reference Updates: When visual changes are intentional, update references with
GENERATE_REFERENCES=1
- Artifact Inspection: Test failures produce visual diffs that can be downloaded as artifacts
Creating New Visual Tests
To create a new visual test:
- Create a test fixture that sets up the specific visual scenario
- Use
request_screenshot()
to capture the scene - Run your test with
GENERATE_REFERENCES=1
to create the initial reference images - Verify the reference images match your expectations
- Run without the flag to ensure tests pass
Headless Rendering
For CI environments, we use Xvfb (X Virtual Framebuffer) to provide a virtual display for rendering. This allows tests to run in headless environments like GitHub Actions.
The visual testing system uses a special headless configuration with:
- Fixed window size for deterministic rendering
- Vulkan backend for better compatibility
- Low power mode for CI environments
- Invisible windows to avoid flickering on CI servers
Troubleshooting
If tests are failing unexpectedly:
- Download Artifacts: Check the visual diff artifacts from the GitHub Actions workflow
- Check for Non-Determinism: Ensure your test setup is deterministic
- Verify References: Make sure reference images are up to date with the current visual design
- Check Environment: The test environment should match the CI environment as closely as possible
Best Practices
- Keep visual tests focused on specific components or screens
- Use deterministic values for positions and sizes
- Avoid animation-dependent tests that might be flaky
- Update reference images when intentional design changes are made
Overview
Visual testing in Rummage:
- Verifies UI component rendering
- Catches visual regressions
- Ensures consistent appearance
- Validates UI interactions visually
Testing Approach
Visual testing uses image comparison and automated validation:
- Reference Images: Maintain a set of approved reference images
- Render Comparison: Generate new renders and compare against references
- Pixel Tolerance: Allow small differences to accommodate rendering variations
- Visual Regression Detection: Identify unintended visual changes
Example: Card Rendering Test
#![allow(unused)] fn main() { #[test] fn test_card_rendering() { // Setup test environment with rendering support let mut app = App::new(); app.add_plugins(RenderTestPlugins); // Create test card let card = setup_test_card(&mut app, "Lightning Bolt"); // Render card to texture let texture = render_entity_to_texture(&mut app, card); // Compare with reference image let reference = load_reference_image("cards/lightning_bolt.png"); // Calculate similarity (allowing for minor variations) let comparison = compare_images(texture, reference); // Verify similarity exceeds threshold assert!(comparison.similarity > 0.99, "Card should render correctly with 99% similarity to reference"); // If failed, save diff image for review if comparison.similarity <= 0.99 { save_diff_image("failed_tests/lightning_bolt_diff.png", comparison.diff_image); } } }
UI Component Testing
Test individual UI components for correct rendering:
#![allow(unused)] fn main() { #[test] fn test_mana_symbol_rendering() { // Setup test environment let mut app = App::new(); app.add_plugins(RenderTestPlugins); // Test all mana symbol types let symbol_types = vec!["W", "U", "B", "R", "G", "C", "1", "X"]; for symbol in symbol_types { // Create mana symbol entity let entity = app.world.spawn(( ManaSymbol { symbol: symbol.to_string() }, Transform::default(), Visibility::default(), )).id(); // Render symbol let texture = render_entity_to_texture(&mut app, entity); // Compare with reference let reference = load_reference_image(&format!("mana_symbols/{}.png", symbol)); let comparison = compare_images(texture, reference); // Verify rendering assert!(comparison.similarity > 0.99, "Mana symbol {} should render correctly", symbol); } } }
Layout Testing
Test that UI layouts render correctly at different resolutions:
#![allow(unused)] fn main() { #[test] fn test_battlefield_layout() { // Setup test environment let mut app = App::new(); app.add_plugins(RenderTestPlugins); // Test different screen resolutions let resolutions = vec![ (1280, 720), // HD (1920, 1080), // Full HD (2560, 1440), // QHD (3840, 2160), // 4K ]; for (width, height) in resolutions { // Set resolution app.world.resource_mut::<RenderSettings>().resolution = (width, height); // Setup a basic battlefield with some cards setup_test_battlefield(&mut app, 5); // 5 cards on battlefield // Render full battlefield let texture = render_screen_to_texture(&mut app); // Compare with reference for this resolution let reference = load_reference_image(&format!("layouts/battlefield_{}x{}.png", width, height)); let comparison = compare_images(texture, reference); // Verify layout is correct assert!(comparison.similarity > 0.98, "Battlefield layout should render correctly at {}x{}", width, height); } } }
Animation Testing
Test that animations render correctly:
#![allow(unused)] fn main() { #[test] fn test_card_draw_animation() { // Setup test environment let mut app = App::new(); app.add_plugins(RenderTestPlugins); // Setup player with library and hand let player = setup_test_player(&mut app); // Set up animation capture let frames = capture_animation_frames(&mut app, 30, || { // Trigger card draw animation app.world.send_event(DrawCardEvent { player }); app.update(); }); // Check key frames against references let key_frame_indices = vec![0, 10, 20, 29]; // Start, middle, end frames for idx in key_frame_indices { let frame = &frames[idx]; let reference = load_reference_image(&format!("animations/draw_card_frame_{}.png", idx)); let comparison = compare_images(frame, reference); assert!(comparison.similarity > 0.97, "Animation frame {} should match reference", idx); } } }
Accessibility Visual Testing
Test accessibility features visually:
#![allow(unused)] fn main() { #[test] fn test_high_contrast_mode() { // Setup test environment let mut app = App::new(); app.add_plugins(RenderTestPlugins); // Enable high contrast mode app.world.resource_mut::<AccessibilitySettings>().high_contrast_mode = true; // Render battlefield with cards setup_test_battlefield(&mut app, 3); let texture = render_screen_to_texture(&mut app); // Compare with high contrast reference let reference = load_reference_image("accessibility/high_contrast_battlefield.png"); let comparison = compare_images(texture, reference); assert!(comparison.similarity > 0.98, "High contrast mode should render correctly"); // Verify contrast ratios meet WCAG guidelines let contrast_analysis = analyze_contrast_ratios(texture); assert!(contrast_analysis.min_ratio >= 4.5, "Minimum contrast ratio should meet WCAG AA standard"); } }
Testing CI Pipeline Integration
Visual tests can be integrated into CI/CD pipelines:
#![allow(unused)] fn main() { // This code would be in your CI setup, not an actual test fn setup_visual_testing_ci() { // Run all visual tests let test_results = run_visual_tests(); // Process results if !test_results.all_passed { // Generate report with diffs let report = generate_visual_diff_report(test_results); // Upload diffs as artifacts upload_artifacts(report.diff_images); // Fail the build std::process::exit(1); } } }
Best Practices
For effective visual testing in Rummage:
- Maintain Reference Images: Keep a versioned set of approved reference images
- Use Appropriate Tolerance: Allow for minor rendering differences across platforms
- Test Multiple Resolutions: Verify UI works across different screen sizes
- Automate Visual Testing: Integrate visual tests into CI/CD pipelines
- Test Accessibility Modes: Verify high-contrast and other accessibility features
- Generate Visual Reports: Create visual reports for failed tests
- Test With Different Themes: Verify rendering in all visual themes
Related Documentation
For more information on testing in Rummage, see:
Visual Differential Testing
Visual differential testing is a technique to automatically detect visual changes between versions of the codebase. Rummage implements a robust visual testing system that integrates with the save/load/replay system to provide powerful testing capabilities.
Core Features
- Save Game Snapshots: Capture the visual state of any saved game
- Replay Point Captures: Take snapshots at specific points in game replays
- Visual Comparison: Compare images against reference images
- Difference Visualization: Generate visual difference maps
- CI Integration: Run visual tests in continuous integration pipelines
Basic Usage
Manually Capturing a Screenshot
#![allow(unused)] fn main() { use rummage::tests::visual_testing::capture::{request_screenshot, take_screenshot}; // Take a screenshot of the whole screen let image = take_screenshot(); // Request a screenshot of a specific entity world.send_event(request_screenshot(entity, "my_screenshot.png")); }
Working with Saved Games
#![allow(unused)] fn main() { use rummage::tests::visual_testing::capture::capture_saved_game_snapshot; // Capture a screenshot of the current state of a saved game let image = capture_saved_game_snapshot(world, "my_save", None, None); // Capture a screenshot of a specific turn let image = capture_saved_game_snapshot(world, "my_save", Some(3), None); // Capture a screenshot at a specific replay step let image = capture_saved_game_snapshot(world, "my_save", None, Some(5)); }
Comparing with Reference Images
#![allow(unused)] fn main() { use rummage::tests::visual_testing::comparison::compare_images; use rummage::tests::visual_testing::utils::load_reference_image; // Load a reference image let reference = load_reference_image("my_reference.png").unwrap(); // Compare with a captured image let result = compare_images(&reference, &captured, 0.1); // Check if there are significant differences if result.has_significant_differences() { // Save the difference visualization result.save_difference("difference.png"); // Take action based on the difference panic!("Visual difference detected!"); } }
Automatic Testing
The visual testing system can automatically test saved games:
#![allow(unused)] fn main() { use rummage::tests::visual_testing::fixtures::test_all_saved_games; // Test all saved games against reference images let results = test_all_saved_games(world); // Check results for result in results { if !result.success { println!("Test failed: {}", result.name); } } }
Integration with Save/Load System
The visual testing system integrates with the save/load system to automatically capture snapshots when:
- A game is saved
- A replay is stepped through
- A game is loaded from a save file
This happens automatically through the following systems:
take_save_game_snapshot
: Captures snapshots when games are savedtake_replay_snapshot
: Captures snapshots during replay steps
CI Testing Pipeline
The visual testing system can be integrated into a CI pipeline:
#![allow(unused)] fn main() { use rummage::tests::visual_testing::ci::{setup_ci_visual_test, is_ci_environment}; // If running in CI environment, configure for CI if is_ci_environment() { setup_ci_visual_test(app); } // Run tests let results = run_visual_tests(); // Report results for result in results { if !result.success { // Upload difference images to CI artifacts upload_artifact(&result.difference_image); } } }
Implementation Details
Screenshot Capture
The system can capture screenshots using different methods:
- Whole Screen: Capture the entire game window
- Entity Focus: Focus on a specific entity
- Camera View: Capture what a specific camera sees
Image Comparison
Images are compared using one of several methods:
- Pixel-by-Pixel: Exact comparison of each pixel
- Histogram: Compare color distributions
- Feature-Based: Compare structural features
- Neural: Use neural networks for perceptual comparison
Testing Configuration
Configure the visual testing system using the VisualTestConfig
resource:
#![allow(unused)] fn main() { use rummage::tests::visual_testing::config::{VisualTestConfig, ComparisonMethod}; // Configure visual testing app.insert_resource(VisualTestConfig { reference_directory: PathBuf::from("reference_images"), difference_directory: PathBuf::from("difference_images"), comparison_method: ComparisonMethod::PixelByPixel, similarity_threshold: 0.95, generate_references: false, }); }
Save/Load Integration for Visual Differential Testing
The Rummage visual testing system integrates with the save/load/replay systems to enable visual differential testing of specific game states. This allows developers to:
- Capture Game State Snapshots: Automatically take screenshots when games are saved
- Replay Visual Validation: Capture visuals during replay for regression testing
- Time-Travel Debugging: Compare visuals at different points in game history
Using Save Game Snapshots
Snapshots are automatically captured when a game is saved:
#![allow(unused)] fn main() { // Save a game, which triggers a snapshot world.send_event(SaveGameEvent { slot_name: "test_save".to_string(), }); }
Using Replay Snapshots
Snapshots are taken at each step during replay:
#![allow(unused)] fn main() { // Step through a replay, capturing visuals at each step world.send_event(StepReplayEvent); }
Testing Game State Evolution
You can use this system to test how the game state evolves visually:
#![allow(unused)] fn main() { #[test] fn test_visual_game_progression() { // Setup game and save initial state setup_game(); world.send_event(SaveGameEvent { slot_name: "initial".to_string() }); // Make game progress play_turn(); // Save and capture the state after progression world.send_event(SaveGameEvent { slot_name: "after_turn".to_string() }); // Compare the visuals between states assert_visual_difference("initial", "after_turn", 0.2); } }
Implementation
The integration between save/load and visual systems is handled by:
- The
SaveGameSnapshot
component attached to cameras during save/load operations take_save_game_snapshot
andtake_replay_snapshot
systems that respond to game events- The visual testing framework that can compare snapshot images
This integration enables automated visual regression testing against saved game states, providing a powerful tool for validating game behavior.
Test Fixtures
Snapshot Testing
This document provides an overview of snapshot testing in the Rummage project. For detailed implementation of the snapshot system itself, please see Snapshot System Testing.
Overview
Snapshot testing is a critical component of Rummage's testing strategy, allowing us to capture, store, and compare game states at different points in time. This approach is especially valuable for testing a complex rules engine like Magic: The Gathering, where many interactions can affect the game state in subtle ways.
Benefits of Snapshot Testing
- Deterministic Testing: Ensures game logic produces consistent, reproducible results
- Regression Prevention: Quickly identifies when changes affect existing functionality
- Complex State Verification: Makes it easier to verify complex game states
- Interaction Testing: Facilitates testing of multi-step card interactions
Snapshot Testing in MTG
In the context of Magic: The Gathering, snapshot testing helps verify:
- Rule Correctness: Ensures rules are applied correctly
- Card Interactions: Validates complex interactions between cards
- Format-Specific Rules: Tests special rules for formats like Commander
Integration with Other Testing Types
Snapshot testing complements other testing approaches:
- Unit Tests: Verify individual components in isolation
- Integration Tests: Test how components work together
- End-to-End Tests: Test the entire game flow
Using Snapshots in Tests
#![allow(unused)] fn main() { #[test] fn test_lightning_bolt_damage() { // Set up the game state let mut game = setup_test_game(); let player = game.add_player(20); // Player with 20 life // Cast Lightning Bolt targeting the player let lightning_bolt = game.create_card("Lightning Bolt"); game.cast(lightning_bolt, Some(player)); // Take a snapshot before resolution let pre_resolution = game.create_snapshot(); // Resolve the spell game.resolve_top_of_stack(); // Take a snapshot after resolution let post_resolution = game.create_snapshot(); // Verify the player's life total decreased by 3 assert_eq!(pre_resolution.get_player_life(player), 20); assert_eq!(post_resolution.get_player_life(player), 17); // Save the snapshots for future regression tests game.save_snapshot("lightning_bolt_pre", pre_resolution); game.save_snapshot("lightning_bolt_post", post_resolution); } }
Best Practices
- Targeted Snapshots: Capture only the relevant parts of game state
- Clear Naming: Use descriptive names for snapshots
- Minimal Setup: Keep test setup as simple as possible
- Deterministic Inputs: Ensure tests have consistent inputs (e.g., fix RNG seeds)
- Review Changes: Carefully review snapshot changes in pull requests
Related Documentation
- Snapshot System Overview
- Snapshot System Implementation
- Snapshot System Testing
- Integration Testing
- End-to-End Testing
CI/CD Pipeline
This document describes the continuous integration and continuous deployment pipeline for the Rummage project.
Overview
Rummage employs a comprehensive CI/CD pipeline to ensure code quality, prevent regressions, and automate the build and deployment process. The pipeline is designed to catch issues early and provide rapid feedback to developers.
Pipeline Structure
Our CI/CD pipeline consists of the following stages:
-
Code Validation
- Linting and formatting checks
- Static analysis
- Dependency vulnerability scanning
-
Unit Testing
- Fast unit tests run on every PR
- Component and system validation
- Rule implementation verification
-
Integration Testing
- System interaction tests
- Game flow validation
- ECS pattern verification
-
End-to-End Testing
- Complete game scenario tests
- Cross-system integration tests
- Performance benchmarks
-
Build and Packaging
- Multi-platform builds
- Asset bundling
- Documentation generation
-
Deployment
- Development environment deployment
- Release candidate publishing
- Production deployment
GitHub Actions Workflow
The pipeline is implemented using GitHub Actions with the following key workflows:
Pull Request Checks
Triggered on every PR to the main branch:
name: All Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Build and test
run: |
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo build --all-features
cargo test --all-features -- --nocapture
This workflow performs:
- Code formatting check
- Static analysis with Clippy
- Full build with all features
- Comprehensive test suite execution
Visual Testing
We have a dedicated workflow for visual regression testing:
name: Visual Testing
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
visual-tests:
runs-on: ubuntu-latest
steps:
# Setup steps...
- name: Run visual tests with Xvfb
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1280x720x24" \
cargo nextest run --package rummage --lib "tests::visual_testing::" -- \
--test-threads=1 \
--no-capture
This workflow:
- Runs in a headless environment using Xvfb
- Captures screenshots of game states
- Compares against reference images
- Uploads difference artifacts for visual inspection
Documentation Deployment
Automatically builds and deploys documentation:
name: Documentation Deployment
on:
push:
branches: [ main ]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build documentation
run: cargo doc --no-deps --document-private-items
- name: Deploy documentation
uses: actions/upload-artifact@v3
with:
name: rummage-docs
path: target/doc
Local Development Integration
The CI/CD pipeline is also integrated with local development through pre-commit hooks that run a subset of the pipeline checks locally:
#!/bin/sh
# Pre-commit hook for Rummage development
# Install with: cp .github/hooks/pre-commit .git/hooks/ && chmod +x .git/hooks/pre-commit
echo "Running pre-commit checks..."
# Format code
cargo fmt -- --check
if [ $? -ne 0 ]; then
echo "Error: Code formatting issues detected"
exit 1
fi
# Run clippy
cargo clippy -- -D warnings
if [ $? -ne 0 ]; then
echo "Error: Clippy warnings detected"
exit 1
fi
# Run fast tests
cargo test --lib
if [ $? -ne 0 ]; then
echo "Error: Unit tests failed"
exit 1
fi
echo "All pre-commit checks passed!"
exit 0
Test Coverage Reporting
The pipeline includes test coverage reporting to track which parts of the codebase are well-tested:
- Coverage Generation: Using tools like tarpaulin to generate coverage reports
- Coverage Visualization: Uploading coverage reports to services like Codecov
- Minimum Coverage Requirements: Enforcing minimum coverage thresholds
Performance Regression Testing
To catch performance regressions early:
- Benchmark Tracking: Storing benchmark results across commits
- Performance Alerts: Notifying developers of significant performance changes
- Resource Profiling: Monitoring memory usage and CPU utilization
Related Documentation
Development Integration
This document describes how testing is integrated with the development workflow in the Rummage project.
Overview
Testing in Rummage is not a separate phase but an integral part of the development process. This integration ensures that code quality is maintained from the earliest stages of development, reducing bugs, preventing regressions, and improving overall code quality.
Test-Driven Development
Rummage follows a test-driven development (TDD) approach for implementing MTG rules and game mechanics:
- Write Tests First: Before implementing a feature, write tests that define the expected behavior
- Run Tests (Fail): Run the tests to confirm they fail as expected
- Implement Feature: Write the minimal code needed to pass the tests
- Run Tests (Pass): Verify the tests now pass
- Refactor: Clean up the code while maintaining passing tests
Example TDD Workflow for MTG Rules
When implementing a new MTG rule, such as the "Legend Rule":
#![allow(unused)] fn main() { // 1. First, write the test #[test] fn test_legend_rule() { // Setup test environment let mut app = App::new(); app.add_plugins(TestingPlugins); // Create a player let player = app.world.spawn(PlayerMarker).id(); let battlefield = app.world.spawn(BattlefieldMarker).id(); // Create first legendary creature let legend1 = app.world.spawn(( CardMarker, Creature { power: 3, toughness: 3 }, Legendary, Name("Tarmogoyf".to_string()), InZone { zone: battlefield }, Controller { player }, )).id(); // Create second legendary creature with same name let legend2 = app.world.spawn(( CardMarker, Creature { power: 3, toughness: 3 }, Legendary, Name("Tarmogoyf".to_string()), InZone { zone: battlefield }, Controller { player }, )).id(); // Apply state-based actions app.world.send_event(CheckStateBasedActions); app.update(); // Verify only one legendary creature remains on battlefield let legend_count = app.world.query_filtered::<(), (With<Legendary>, With<CardMarker>)>() .iter(&app.world) .count(); assert_eq!(legend_count, 1, "Only one legendary creature should remain after state-based actions"); } // 2. Implement the rule fn apply_legend_rule( mut commands: Commands, players: Query<(Entity, &PlayerZones)>, legends: Query<(Entity, &Controller, &Name), (With<Legendary>, With<CardMarker>)>, ) { // Group legends by controller and name let mut legend_groups = HashMap::new(); for (legend_entity, controller, name) in legends.iter() { legend_groups .entry((controller.player, name.0.clone())) .or_insert_with(Vec::new) .push(legend_entity); } // For each group with more than one legend, keep only the newest for (_, legends) in legend_groups.iter() { if legends.len() <= 1 { continue; } // Keep the first one, sacrifice the rest for &legend_entity in legends.iter().skip(1) { // Move to graveyard // ... implementation details ... } } } }
Continuous Integration Hooks
Development is integrated with the CI/CD pipeline through:
- Pre-commit Hooks: Automatically run tests before allowing commits
- PR Validation: Enforce passing tests and code standards on PR submission
- Integration Gates: Prevent merges that would break existing functionality
See the CI/CD Pipeline documentation for details on these integration points.
Development Environments
Test integration in different development contexts:
Local Development
For local development:
-
Watch Mode: Tests run automatically when files change
cargo watch -x "test --lib"
-
Test Filters: Run specific tests during focused development
cargo test combat -- --nocapture
-
Debug Tests: Run tests with debugging enabled
rust-lldb target/debug/deps/rummage-1234abcd
IDE Integration
Integration with common development environments:
-
VS Code:
- Run/debug tests from within the editor
- Visualize test coverage
- Code lens for test navigation
-
IntelliJ/CLion:
- Run tests from gutter icons
- Debug test failures
- View test history
Test Fixtures and Helpers
To streamline the development process, Rummage provides:
-
Test Fixtures: Common test setups for frequently tested scenarios
#![allow(unused)] fn main() { // Use a fixture for standard game setup let (mut app, player1, player2) = setup_two_player_game(); }
-
Test Utilities: Helper functions for common test operations
#![allow(unused)] fn main() { // Utility to simplify card creation let lightning_bolt = create_test_card(&mut app, "Lightning Bolt"); }
-
Mock Systems: Simplified systems for testing in isolation
#![allow(unused)] fn main() { // Replace network systems with mock implementation app.add_plugin(MockNetworkPlugin); }
Development Tools
Tools that integrate testing into development:
- Snapshot Review Tool: Visual interface for reviewing snapshot tests
- Coverage Reports: Interactive coverage visualization during development
- Performance Monitors: Real-time performance metrics during testing
Best Practices
Guidelines for integrating testing with development:
- Write Tests Alongside Code: Tests should be in the same PR as implementation
- Maintain Test Coverage: Don't let coverage drop as code grows
- Test First for Bug Fixes: Always reproduce bugs with tests before fixing
- Run Full Suite Regularly: Don't rely only on focused tests
- Document Test Limitations: Make clear what aspects aren't covered by tests
Related Documentation
Development Guide
This section provides comprehensive information for developers who want to contribute to or work with the Rummage MTG Commander game engine.
Table of Contents
- Introduction
- Key Development Areas
- Development Environment
- Working with Bevy
- Integration with Testing
Introduction
The Rummage development guide is designed to help developers understand the architecture, code style, and development practices used in the project. Whether you're a new contributor or an experienced developer, this guide will help you navigate the codebase and make effective contributions.
Rummage follows a test-driven development approach, which means testing is an integral part of the development process. Understanding how development and testing interact will help you create robust, maintainable code that correctly implements the complex MTG rule system.
Key Development Areas
The development documentation is organized into these key areas:
-
- Setting up your development environment
- Building and running the project
- First steps for new contributors
-
- High-level system architecture
- Component relationships
- Design patterns used
-
- Coding conventions
- Documentation standards
- Best practices
-
- Entity Component System - Understanding and working with ECS
- Plugin Architecture - Creating and using plugins
- Rendering - Card rendering and UI components
-
- Snapshot System - Game state serialization and replay functionality
- Testing - How core systems integrate with testing
Development Environment
To work with Rummage, we recommend the following tools and configurations:
Required Tools
- Rust (latest stable version)
- Cargo (comes with Rust)
- Git
- A compatible IDE (Visual Studio Code with rust-analyzer recommended)
Recommended Extensions
For Visual Studio Code:
- rust-analyzer: For Rust language support
- CodeLLDB: For debugging Rust applications
- Better TOML: For editing TOML configuration files
Building the Project
Basic build commands:
# Build in debug mode
cargo build
# Build in release mode
cargo build --release
# Run the application
cargo run
# Run tests
cargo test
Working with Bevy
Rummage is built on the Bevy game engine, which provides a data-driven, entity-component-system (ECS) architecture. The Working with Bevy section provides detailed guidance on:
- Understanding ECS: How Rummage organizes game elements into entities, components, and systems
- Plugin Development: Creating and working with Bevy plugins
- Rendering Systems: Implementing visual elements and UI components
- Bevy 0.15.x Specifics: Working with the latest Bevy APIs
Bevy 0.15.x introduces some important changes, including deprecated UI components like Text2dBundle
, SpriteBundle
, and NodeBundle
which are replaced by Text2d
, Sprite
, and Node
respectively. Our documentation provides guidance on using these newer APIs correctly.
Integration with Testing
Testing is a foundational aspect of Rummage development, ensuring that our implementation correctly follows MTG rules and maintains compatibility across system changes. Our Testing Overview provides comprehensive information on our testing approach.
Test-Driven Development Workflow
When developing new features for Rummage, we follow this test-driven workflow:
- Document the Feature: Define requirements and behavior in the documentation
- Write Tests First: Create tests that verify the expected behavior
- Implement the Feature: Write code that passes the tests
- Refactor: Improve the implementation while maintaining test coverage
- Integration Testing: Ensure the feature works correctly with other systems
Testing Infrastructure in Development
Our development process is tightly integrated with testing:
- ECS System Testing: Use
ParamSet
and other techniques described in the ECS Guide to avoid runtime panics - Snapshot Testing: Leverage the Snapshot System for deterministic state verification
- Visual Testing: For UI components, use our visual differential testing tools
MTG Rule Verification
When implementing MTG rules, refer to both:
- The MTG Rules Reference for authoritative rules text
- The Testing Guidelines for advice on verifying rule implementation
By integrating testing throughout the development process, we ensure that Rummage maintains a high level of quality and accurately implements the complex MTG rule system.
Contributing
We welcome contributions to the Rummage project! Please see our contribution guidelines for information on how to submit changes, report issues, and suggest improvements. For specific guidance on our git workflow and commit message format, refer to our Git Workflow Guidelines.
Next Steps
To start developing with Rummage, we recommend:
- Read the Getting Started guide
- Review the Architecture Overview
- Familiarize yourself with Bevy ECS concepts
- Review the Testing Overview to understand our testing approach
- Check out the API Reference for detailed information on specific components and systems
For questions or assistance, please reach out to the development team through the project's GitHub repository.
Getting Started
Architecture Overview
Code Style
Working with Bevy
This section provides detailed guidance on working with the Bevy game engine in the Rummage project. Bevy is a data-driven game engine built in Rust that uses an Entity Component System (ECS) architecture, which is particularly well-suited for building complex game systems like those required for a Magic: The Gathering implementation.
Table of Contents
Introduction
Rummage uses Bevy 0.15.x as its foundation, leveraging Bevy's modular design and performant ECS to create a robust MTG Commander game engine. This section explains how we use Bevy, our architecture patterns, and best practices for working with Bevy components, systems, and resources.
Key Bevy Concepts
Before diving into Rummage-specific patterns, it's important to understand these core Bevy concepts:
- Entity: A unique ID that can have components attached to it
- Component: Data attached to entities (e.g.,
Card
,Player
,Battlefield
) - System: Logic that operates on components (e.g., drawing cards, resolving abilities)
- Resource: Global data not tied to any specific entity (e.g.,
GameState
,RngResource
) - Plugin: A collection of systems, components, and resources that can be added to the app
- Query: A way to access entities and their components in systems
- Events: Message-passing mechanism between systems
Rummage Bevy Patterns
Rummage follows these patterns for Bevy implementation:
- Domain-Specific Plugins: Each game domain (cards, player, zones, etc.) has its own plugin
- Component-Heavy Design: Game state is represented primarily through components
- Event-Driven Interactions: Game actions are often communicated via events
- State-Based Architecture: Game flow is controlled through Bevy's state system
- Clean Resource Management: Resources are used judiciously for truly global state
Bevy Version Considerations
Rummage uses Bevy 0.15.x, which introduces some important changes from earlier versions:
- Deprecated UI Components:
Text2dBundle
,SpriteBundle
, andNodeBundle
are deprecated in favor ofText2d
,Sprite
, andNode
respectively - App Initialization: Modified approach to app configuration and plugin registration
- System Sets: Updated system ordering mechanism
- Asset Loading: Enhanced asset loading system
- Time Management: Improved time and timer APIs
Always check for and fix any compiler warnings that might indicate usage of deprecated APIs.
Detailed Guides
For more detailed information on working with Bevy in the Rummage project, explore these specific topics:
- Entity Component System - Detailed guide on how Rummage uses ECS architecture
- Plugin Architecture - How plugins are organized and composed in Rummage
- Rendering - Card rendering, UI, and visual effects implementation
- Camera Management - Camera setup, management, and common issues
- Random Number Generation - Using bevy_rand for entity-attached RNGs and networked state sharing
Next: Entity Component System
Entity Component System
This guide explains how the Entity Component System (ECS) architecture is implemented in Rummage using Bevy, and provides practical advice for working with ECS patterns.
Table of Contents
- Introduction to ECS
- ECS in Bevy
- Game Entities in Rummage
- Component Design
- System Design
- Queries and Filters
- ECS Best Practices
- Common Pitfalls
- Safely Using Parameter Sets
- Understanding Parameter Sets
- Disjoint Queries with Param Sets
- Using Component Access for Safety
- Avoiding World References
- Query Lifetimes and Temporary Storage
- Using System Sets for Dependency Management
- Testing for Query Conflicts
- Working with Snapshot Systems
- Debugging Snapshot Systems with Trace Logging
- MTG-Specific Example: Card Manipulation Safety
Introduction to ECS
Entity Component System (ECS) is an architectural pattern that separates identity (entities), data (components), and logic (systems). This separation offers several advantages:
- Performance: Enables cache-friendly memory layouts and parallel execution
- Flexibility: Allows for dynamic composition of game objects
- Modularity: Decouples data from behavior for better code organization
- Extensibility: Makes it easier to add new features without modifying existing code
ECS in Bevy
Bevy's ECS implementation includes these core elements:
Entities
Entities in Bevy are simply unique identifiers that components can be attached to. They're created using the Commands
API:
#![allow(unused)] fn main() { // Creating a new entity commands.spawn_empty(); // Creating an entity with components commands.spawn(( Card { id: "fireball", cost: "{X}{R}" }, CardName("Fireball".to_string()), SpellType::Instant, )); }
Components
Components are simple data structures that can be attached to entities:
#![allow(unused)] fn main() { // Component definition #[derive(Component)] struct Health { current: i32, maximum: i32, } // Component with derive macros for common functionality #[derive(Component, Debug, Clone, PartialEq)] struct ManaCost { blue: u8, black: u8, red: u8, green: u8, white: u8, colorless: u8, } }
Systems
Systems are functions that operate on components:
#![allow(unused)] fn main() { // Simple system that operates on Health components fn heal_system(mut query: Query<&mut Health>) { for mut health in &mut query { health.current = health.current.min(health.maximum); } } // System that uses multiple component types fn damage_system( mut commands: Commands, mut query: Query<(Entity, &mut Health, &DamageReceiver)>, time: Res<Time>, ) { for (entity, mut health, damage) in &mut query { health.current -= damage.amount; if health.current <= 0 { commands.entity(entity).insert(DeathMarker); } } } }
Resources
Resources are global singleton data structures:
#![allow(unused)] fn main() { // Resource definition #[derive(Resource)] struct GameState { turn: usize, phase: Phase, active_player: usize, } // Accessing resources in systems fn turn_system(mut game_state: ResMut<GameState>) { game_state.turn += 1; // ... } }
Game Entities in Rummage
Rummage represents game concepts as entities with appropriate components:
Cards
Cards are entities with components like:
Card
- Core card dataCardName
- The card's nameManaCost
- Mana cost informationCardType
- Card type information- Position components for visual placement
Players
Players are entities with components like:
Player
- Player informationLife
- Current life totalHand
- Reference to hand entityCommander
- Reference to commander entityLibrary
- Reference to library entity
Zones
Game zones (like battlefield, graveyard) are entities with components like:
Zone
- Zone type and metadataZoneContents
- References to contained entities- Visual placement components
Component Design
When designing components for Rummage, follow these guidelines:
Keep Components Focused
Components should represent a single aspect of an entity. For example, separate Health
, AttackPower
, and BlockStatus
rather than a single CombatStats
component.
Efficient Component Storage
Consider the memory layout of components:
- Use primitive types where possible
- For small fixed-size collections, use arrays instead of Vecs
- For larger collections, consider using entity references instead of direct data storage
Component Relationships
Use entity references to establish relationships between components:
#![allow(unused)] fn main() { #[derive(Component)] struct Attachments { attached_to: Entity, attached_cards: Vec<Entity>, } }
System Design
Systems should follow these design principles:
Single Responsibility
Each system should have a clear, well-defined responsibility. For example:
draw_card_system
- Handles drawing cards from library to handapply_damage_system
- Applies damage to creatures and playerscheck_state_based_actions
- Checks and applies state-based actions
System Organization
Systems are organized in the codebase by domain:
card/systems.rs
- Card-related systemscombat/systems.rs
- Combat-related systemsplayer/systems.rs
- Player-related systems
System Scheduling
Bevy 0.15 uses system sets for scheduling. Rummage organizes systems into sets like:
#![allow(unused)] fn main() { #[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)] enum CardSystemSet { Draw, Play, Resolve, } app .configure_sets( Update, (CardSystemSet::Draw, CardSystemSet::Play, CardSystemSet::Resolve).chain() ) .add_systems( Update, (draw_card, mill_cards).in_set(CardSystemSet::Draw) ); }
Now that we understand how systems are organized and scheduled, let's explore how systems access and manipulate entity data through queries.
Queries and Filters
Queries are the primary way to access entity data in systems. Here are some common query patterns used in Rummage:
Basic Queries
#![allow(unused)] fn main() { // Query for a single component type fn system(query: Query<&Card>) { for card in &query { // Use card data } } // Query for multiple component types fn system(query: Query<(&Card, &CardName, &ManaCost)>) { for (card, name, cost) in &query { // Use all components } } // Query with mutable access fn system(mut query: Query<&mut Health>) { for mut health in &mut query { health.current += 1; } } }
Filtering Queries
#![allow(unused)] fn main() { // Filter to only entities with specific components fn system(query: Query<&Card, With<Creature>>) { // Only processes cards that are creatures } // Filter to exclude entities with specific components fn system(query: Query<&Card, Without<Tapped>>) { // Only processes cards that aren't tapped } // Combining filters fn system(query: Query<&Card, (With<Creature>, Without<Tapped>)>) { // Only processes creature cards that aren't tapped } }
Entity Access
#![allow(unused)] fn main() { // Getting the entity ID along with components fn system(query: Query<(Entity, &Card)>) { for (entity, card) in &query { // Use entity ID and card } } // Looking up a specific entity fn system( commands: Commands, query: Query<&Card>, player_query: Query<&Player> ) { if let Ok(player) = player_query.get(player_entity) { // Use player data } } }
ECS Best Practices
Performance Considerations
- Batch operations: Use commands.spawn_batch() for creating multiple similar entities
- Query optimization: Be specific about which components you query
- Change detection: Use Changed
to only run logic when components change - Parallelism awareness: Design systems to avoid conflicts that would prevent parallelism
Maintainable Code
- Document component purposes: Each component should have clear documentation
- System naming: Use clear, descriptive names for systems
- Consistent patterns: Follow established patterns for similar features
- Tests: Write unit tests for systems and component interactions
Common Pitfalls
Multiple Mutable Borrows
Bevy will panic if you try to mutably access the same component multiple times in a system.
Problem:
#![allow(unused)] fn main() { fn problematic_system(mut query: Query<(&mut Health, &mut Damage)>) { // This could panic if an entity has both Health and Damage components } }
Solution:
#![allow(unused)] fn main() { fn fixed_system( mut health_query: Query<&mut Health>, damage_query: Query<(Entity, &Damage)>, ) { for (entity, damage) in &damage_query { if let Ok(mut health) = health_query.get_mut(entity) { health.current -= damage.amount; } } } }
Query For Single Entity
When you expect a single entity to match a query but get multiple, Bevy will panic with a "MultipleEntities" error.
Problem:
#![allow(unused)] fn main() { fn get_camera_system(camera_query: Query<(&Camera, &GlobalTransform)>) { // This will panic if there are multiple camera entities let (camera, transform) = camera_query.single(); } }
Solution:
#![allow(unused)] fn main() { fn get_camera_system(camera_query: Query<(&Camera, &GlobalTransform), With<MainCamera>>) { // Add a marker component to your main camera if let Ok((camera, transform)) = camera_query.get_single() { // Now we only get the one with the MainCamera marker } } }
Event Overflow
Event readers that don't consume all events can cause memory growth.
Problem:
#![allow(unused)] fn main() { fn card_draw_system(mut event_reader: EventReader<DrawCardEvent>) { // Only process the first event each frame if let Some(event) = event_reader.iter().next() { // Process one event, leaving others unconsumed } } }
Solution:
#![allow(unused)] fn main() { fn card_draw_system(mut event_reader: EventReader<DrawCardEvent>) { // Process all events for event in event_reader.iter() { // Process each event } } }
The pitfalls discussed above highlight some of the common issues you might encounter when working with Bevy's ECS. In the next section, we'll explore more advanced techniques to prevent these issues from occurring in the first place.
Safely Using Parameter Sets
Bevy's ECS enforces strict borrowing rules to maintain memory safety and enable parallelism. A common cause of runtime panics is query parameter conflicts, especially when working with complex systems. This section covers techniques to write robust systems that avoid these issues.
Understanding Parameter Sets
Parameter sets provide a way to group related parameters and control how they interact with each other. By explicitly defining parameter sets, you can prevent Bevy from attempting to run systems with conflicting queries in parallel, which would cause runtime panics.
Disjoint Queries with Param Sets
The ParamSet
type allows you to create multiple queries that would otherwise conflict with each other:
#![allow(unused)] fn main() { use bevy::ecs::system::ParamSet; fn safe_system( mut param_set: ParamSet<( Query<&mut Transform, With<Player>>, Query<&mut Transform, With<Enemy>> )> ) { // Access the first query (player transforms) for mut transform in param_set.p0().iter_mut() { // Modify player transforms } // Access the second query (enemy transforms) for mut transform in param_set.p1().iter_mut() { // Modify enemy transforms } } }
This approach is safer than trying to use separate queries because ParamSet
guarantees that access to each query is sequential rather than simultaneous.
Using Component Access for Safety
For more complex systems, you can use the ComponentAccess
trait to explicitly control which components your system accesses:
#![allow(unused)] fn main() { #[derive(Default, Resource)] struct SafeComponentAccess { processing_cards: bool, } fn card_system( mut access: ResMut<SafeComponentAccess>, mut query: Query<&mut Card>, ) { // Set flag to indicate we're processing cards access.processing_cards = true; for mut card in &mut query { // Process cards safely } // Release the lock access.processing_cards = false; } fn other_card_system( access: Res<SafeComponentAccess>, mut commands: Commands, ) { // Check if another system is processing cards if !access.processing_cards { // Safe to spawn or modify cards commands.spawn(Card::default()); } } }
Avoiding World References
While it's possible to access the entire ECS World
in a system, this approach bypasses Bevy's safety mechanisms and should be avoided whenever possible:
Problematic Approach:
#![allow(unused)] fn main() { fn unsafe_world_system(world: &mut World) { // Direct world access bypasses Bevy's safety checks let mut cards = world.query::<&mut Card>(); for mut card in cards.iter_mut(world) { // This might conflict with other systems } } }
Safer Alternative:
#![allow(unused)] fn main() { fn safe_system(mut query: Query<&mut Card>) { for mut card in &mut query { // Bevy will handle safety and scheduling } } }
Query Lifetimes and Temporary Storage
This example shows how to safely implement card manipulation systems that would otherwise conflict with each other. By either using ParamSet
or breaking the operation into separate systems with clear dependencies, we avoid the common causes of ECS panics.
Testing for Query Conflicts
#![allow(unused)] fn main() { #[test] fn verify_system_sets_compatibility() { let mut app = App::new(); // Add systems that should be compatible app.add_systems(Update, (system_a, system_b)); // Verify no conflicts using Bevy's built-in detection app.world_mut().get_archetypes(); } }
While the techniques above apply broadly to all ECS systems, some specific system types present unique challenges. One particularly complex area in game development is state snapshotting and replay, which we'll explore next.
Working with Snapshot Systems
Game state snapshot systems can be particularly prone to query conflicts since they often need to access a wide range of components. Here are patterns to make snapshot systems more robust:
Isolating Snapshot Systems
Place snapshot-related systems in dedicated sets that run at specific points in the frame:
#![allow(unused)] fn main() { #[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)] enum SnapshotSystemSet { PrepareSnapshot, ProcessEvents, ApplySnapshot, } app .configure_sets( Update, ( // Run snapshot systems after regular game systems GameSystemSet::All, SnapshotSystemSet::PrepareSnapshot, SnapshotSystemSet::ProcessEvents, SnapshotSystemSet::ApplySnapshot, ).chain() ); }
Deferred Snapshot Processing
Instead of trying to query and modify components immediately, gather snapshot data and defer processing:
#![allow(unused)] fn main() { #[derive(Resource, Default)] struct PendingSnapshots { snapshots: Vec<GameSnapshot>, } // First system: collect snapshot data fn handle_snapshot_events( mut event_reader: EventReader<SnapshotEvent>, mut pending: ResMut<PendingSnapshots>, query: Query<&GameState>, ) { for event in event_reader.iter() { // Collect necessary data without modifying anything let snapshot = create_snapshot(&query, event); pending.snapshots.push(snapshot); } } // Second system: process collected snapshots fn process_pending_snapshots( mut commands: Commands, mut pending: ResMut<PendingSnapshots>, ) { for snapshot in pending.snapshots.drain(..) { // Now apply changes using commands apply_snapshot(&mut commands, snapshot); } } }
Read-Only Snapshots
When possible, make snapshots read-only operations that don't modify components directly:
#![allow(unused)] fn main() { fn create_snapshot( query: Query<(Entity, &Transform, &Health), With<Snapshotable>>, ) -> GameSnapshot { let mut snapshot = GameSnapshot::default(); for (entity, transform, health) in &query { snapshot.entities.push(SnapshotEntry { entity, position: transform.translation, health: health.current, }); } snapshot } }
Command-Based Modifications
When applying snapshots, use Commands to defer actual entity modifications:
#![allow(unused)] fn main() { fn apply_snapshot_system( mut commands: Commands, snapshots: Res<SnapshotRepository>, entities: Query<Entity, With<Snapshotable>>, ) { if let Some(snapshot) = snapshots.get_latest() { // First remove outdated entities for entity in &entities { if !snapshot.contains(entity) { commands.entity(entity).despawn_recursive(); } } // Then apply snapshot data using commands for entry in &snapshot.entities { commands.spawn(( Snapshotable, Transform::from_translation(entry.position), Health { current: entry.health, maximum: entry.max_health }, )); } } } }
This approach ensures that entity modifications happen at safe times controlled by Bevy's command buffer system.
Debugging Snapshot Systems with Trace Logging
Snapshot systems can be particularly difficult to debug due to their complex interactions with the ECS. Structured logging can help identify where issues occur:
#![allow(unused)] fn main() { fn handle_snapshot_events( mut event_reader: EventReader<SnapshotEvent>, mut pending: ResMut<PendingSnapshots>, ) { // Log system entry with count of events trace!(system = "handle_snapshot_events", event_count = event_reader.len(), "Entering system"); // Process events for event in event_reader.iter() { trace!(system = "handle_snapshot_events", event_id = ?event.id, "Processing event"); match process_event(event, &mut pending) { Ok(_) => trace!(system = "handle_snapshot_events", event_id = ?event.id, "Successfully processed"), Err(e) => error!(system = "handle_snapshot_events", event_id = ?event.id, error = ?e, "Failed to process"), } } // Log system exit trace!(system = "handle_snapshot_events", "Exiting system"); } }
When debugging snapshot systems, look for these common patterns in logs:
- Systems that enter but never exit (indicating a panic or infinite loop)
- Mismatched counts between processed and expected items
- Systems that execute in unexpected orders
- Repeated errors processing the same entities
For complex debugging, consider a custom snapshot debug viewer:
#![allow(unused)] fn main() { #[derive(Resource)] struct SnapshotDebugger { history: Vec<SnapshotDebugEntry>, active_systems: HashSet<&'static str>, } impl SnapshotDebugger { fn system_enter(&mut self, name: &'static str) { self.active_systems.insert(name); self.history.push(SnapshotDebugEntry { timestamp: std::time::Instant::now(), event: format!("System entered: {}", name), }); } fn system_exit(&mut self, name: &'static str) { self.active_systems.remove(name); self.history.push(SnapshotDebugEntry { timestamp: std::time::Instant::now(), event: format!("System exited: {}", name), }); } } // Add to app startup app.init_resource::<SnapshotDebugger>(); // Modified system with detailed tracing fn handle_snapshot_events( mut debugger: ResMut<SnapshotDebugger>, mut event_reader: EventReader<SnapshotEvent>, mut pending: ResMut<PendingSnapshots>, ) { let system_name = "handle_snapshot_events"; debugger.system_enter(system_name); // System logic here debugger.system_exit(system_name); } }
This approach creates a permanent record of system execution that persists even if the system panics, making it easier to reconstruct what happened.
MTG-Specific Example: Card Manipulation Safety
Now that we've covered the general techniques for safe ECS usage, let's apply these concepts to a concrete example in our Magic: The Gathering implementation. Card manipulation systems are a perfect illustration of where these safety techniques are crucial.
#![allow(unused)] fn main() { // Define components for MTG cards #[derive(Component)] struct Card { id: String, power: Option<i32>, toughness: Option<i32>, } #[derive(Component)] enum CardZone { Battlefield, Graveyard, Hand, Library, Exile, } // ParamSet approach for a card movement system fn move_card_system( mut param_set: ParamSet<( // Queries for different card zones Query<(Entity, &Card), With<CardZone>>, Query<&mut CardZone>, )>, commands: Commands, ) { // First gather all relevant card entities let mut cards_to_move = Vec::new(); // Using the first query to find cards for (entity, card) in param_set.p0().iter() { if should_move_card(card) { cards_to_move.push(entity); } } // Then use the second query to update zone components for entity in cards_to_move { if let Ok(mut zone) = param_set.p1().get_mut(entity) { // Update the zone safely *zone = CardZone::Graveyard; } } } // Alternative approach using system sets for battlefield organization #[derive(SystemSet, Debug, Hash, PartialEq, Eq, Clone)] enum CardSystemSet { Preparation, ZoneChanges, StatUpdates, Cleanup, } // First system: identify cards to organize fn identify_battlefield_cards( query: Query<Entity, With<CardZone>>, mut card_commands: ResMut<CardCommands>, ) { card_commands.to_organize.clear(); for entity in &query { if should_organize(entity) { card_commands.to_organize.push(entity); } } } // Second system: update card positions fn organize_battlefield_cards( mut query: Query<&mut Transform>, card_commands: Res<CardCommands>, ) { for (index, &entity) in card_commands.to_organize.iter().enumerate() { if let Ok(mut transform) = query.get_mut(entity) { // Calculate new position let position = calculate_card_position(index); transform.translation = position; } } } // Register systems in the correct order app .configure_sets( Update, ( CardSystemSet::Preparation, CardSystemSet::ZoneChanges, CardSystemSet::StatUpdates, CardSystemSet::Cleanup, ).chain() ) .add_systems( Update, identify_battlefield_cards.in_set(CardSystemSet::Preparation) ) .add_systems( Update, organize_battlefield_cards.in_set(CardSystemSet::ZoneChanges) ); }
This example shows how to safely implement card manipulation systems that would otherwise conflict with each other. By either using ParamSet
or breaking the operation into separate systems with clear dependencies, we avoid the common causes of ECS panics.
Conclusion
Bevy's ECS provides a powerful foundation for building complex game systems, but it requires careful attention to system design and query patterns to avoid runtime panics. By following the best practices and safety techniques outlined in this guide, you can build robust, maintainable systems for your Magic: The Gathering implementation.
Remember these key principles:
- Keep components focused and well-documented
- Use appropriate query filters to target exactly the entities you need
- Handle potential conflicts with ParamSet and system ordering
- Use Commands for deferred modifications when appropriate
- Add detailed logging for complex system interactions
- Test your systems thoroughly, including compatibility verification
Next: Plugin Architecture
Plugin Architecture
This guide explains how the plugin architecture is implemented in Rummage using Bevy, and provides guidelines for working with and creating plugins.
Table of Contents
- Introduction to Bevy Plugins
- Rummage Plugin Structure
- Core Plugins
- Creating Plugins
- Plugin Dependencies
- Testing Plugins
- Best Practices
Introduction to Bevy Plugins
Bevy's plugin system is a powerful way to organize and modularize game functionality. Plugins can add systems, resources, events, and other game elements to the Bevy App in a self-contained way. This modular approach allows for:
- Code organization: Grouping related functionality
- Reusability: Using plugins across different projects
- Composability: Building complex behavior from simpler plugins
- Testability: Testing plugins in isolation
Rummage Plugin Structure
The Rummage codebase is organized around domain-specific plugins that encapsulate different aspects of the game engine. Here's the high-level plugin architecture:
src/
├── plugins/
│ ├── mod.rs # Exports all plugins
│ └── core.rs # Core plugin configuration
├── game_engine/ # Game engine plugins
│ ├── mod.rs
│ ├── plugin.rs # Main game engine plugin
│ └── ...
├── card/ # Card-related plugins
│ ├── mod.rs
│ ├── plugin.rs # Card system plugin
│ └── ...
├── player/ # Player-related plugins
│ ├── mod.rs
│ ├── plugin.rs # Player system plugin
│ └── ...
└── ...
In Rummage, each major subsystem is implemented as a plugin, which may compose multiple smaller plugins.
Core Plugins
Rummage has several core plugins that provide essential functionality:
GameEnginePlugin
The GameEnginePlugin
is responsible for the core game mechanics:
#![allow(unused)] fn main() { pub struct GameEnginePlugin; impl Plugin for GameEnginePlugin { fn build(&self, app: &mut App) { app // Add game engine resources .init_resource::<GameState>() .init_resource::<TurnState>() // Register game engine events .add_event::<PhaseChangeEvent>() .add_event::<TurnChangeEvent>() // Add game engine systems .add_systems(Update, ( process_game_phase, handle_turn_changes, check_state_based_actions, )); } } }
CardPlugin
The CardPlugin
handles card-related functionality:
#![allow(unused)] fn main() { pub struct CardPlugin; impl Plugin for CardPlugin { fn build(&self, app: &mut App) { app // Add card-related resources .init_resource::<CardDatabase>() // Register card-related events .add_event::<CardDrawnEvent>() .add_event::<CardPlayedEvent>() // Add card-related systems .add_systems(Update, ( load_card_database, process_card_effects, )); } } }
PlayerPlugin
The PlayerPlugin
manages player-related functionality:
#![allow(unused)] fn main() { pub struct PlayerPlugin; impl Plugin for PlayerPlugin { fn build(&self, app: &mut App) { app // Add player-related resources .init_resource::<PlayerRegistry>() // Register player-related events .add_event::<PlayerDamageEvent>() .add_event::<PlayerLifeChangeEvent>() // Add player-related systems .add_systems(Update, ( update_player_life, check_player_elimination, )); } } }
Creating Plugins
When creating a new plugin for Rummage, follow these steps:
- Identify responsibility: Define a clear domain of responsibility for your plugin
- Create plugin structure: Create a new module with the plugin definition
- Implement resources and components: Define data structures needed
- Implement systems: Create systems that operate on your data
- Register with app: Implement the Plugin trait to register everything
Here's a template for creating a new plugin:
#![allow(unused)] fn main() { use bevy::prelude::*; // Define your plugin pub struct MyFeaturePlugin; // Define plugin-specific resources #[derive(Resource, Default)] pub struct MyFeatureResource { // Resource data } // Define plugin-specific components #[derive(Component)] pub struct MyFeatureComponent { // Component data } // Define plugin-specific events #[derive(Event)] pub struct MyFeatureEvent { // Event data } // Define plugin-specific systems fn my_feature_system( mut commands: Commands, query: Query<&MyFeatureComponent>, mut resource: ResMut<MyFeatureResource>, ) { // System logic } // Implement the Plugin trait impl Plugin for MyFeaturePlugin { fn build(&self, app: &mut App) { app // Initialize resources .init_resource::<MyFeatureResource>() // Register events .add_event::<MyFeatureEvent>() // Add systems .add_systems(Update, ( my_feature_system, // Other systems )); } } }
Plugin Dependencies
Plugins often depend on functionality provided by other plugins. In Bevy, plugin dependencies are managed through the order in which plugins are added to the app.
Explicit Ordering
In Rummage, we handle plugin dependencies explicitly in the main app setup:
#![allow(unused)] fn main() { // In main.rs or lib.rs app // Core plugins first .add_plugins(RummageGameCorePlugins) // Dependent plugins next .add_plugin(CardPlugin) .add_plugin(PlayerPlugin) .add_plugin(ZonePlugin) // Higher-level plugins that depend on the above .add_plugin(CombatPlugin) .add_plugin(EffectsPlugin); }
Plugin Groups
For related plugins, we use Bevy's plugin groups to organize them:
#![allow(unused)] fn main() { pub struct RummageGameCorePlugins; impl PluginGroup for RummageGameCorePlugins { fn build(self) -> PluginGroupBuilder { PluginGroupBuilder::start::<Self>() .add(GameStatePlugin) .add(EventLoggingPlugin) .add(AssetLoadingPlugin) } } }
Testing Plugins
Plugins should be tested in isolation as much as possible. Bevy provides utilities for testing plugins.
Unit Testing Plugins
Here's how to test a single system from a plugin:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; use bevy::prelude::*; #[test] fn test_my_feature_system() { // Set up a minimal App with just what we need let mut app = App::new(); // Add resources and register components app.init_resource::<MyFeatureResource>(); // Add the system under test app.add_systems(Update, my_feature_system); // Set up test entities app.world.spawn(MyFeatureComponent { /* ... */ }); // Run the system app.update(); // Assert expected outcomes let resource = app.world.resource::<MyFeatureResource>(); assert_eq!(resource.some_value, expected_value); } } }
Integration Testing Plugins
For testing a complete plugin:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; use bevy::prelude::*; #[test] fn test_my_feature_plugin() { // Set up a minimal App let mut app = App::new(); // Add our plugin app.add_plugin(MyFeaturePlugin); // Set up test entities and initial state // ... // Run systems for a few frames app.update(); app.update(); // Assert expected outcomes // ... } } }
Best Practices
When working with plugins in Rummage, follow these best practices:
Organization
- One domain per plugin: Each plugin should have a single, well-defined responsibility
- Hierarchical structure: Compose complex plugins from simpler ones
- Clear naming: Name plugins descriptively based on functionality
Plugin Design
- Minimal dependencies: Minimize dependencies between plugins
- Configuration options: Make plugins configurable through parameters or resources
- Clean interfaces: Define clear interfaces for inter-plugin communication
Implementation
- Documentation: Document each plugin's purpose and functionality
- Resource naming: Use descriptive, domain-specific names for resources
- Event-based communication: Use events for loose coupling between plugins
- Testability: Design plugins to be testable in isolation
Example: Well-Designed Plugin
#![allow(unused)] fn main() { /// Plugin responsible for handling Magic card drawing mechanics /// /// This plugin manages: /// - Drawing cards from library to hand /// - "Draw X cards" effects /// - Replacement effects for card drawing /// - Events related to card drawing pub struct CardDrawPlugin { /// Maximum hand size (default: 7) pub max_hand_size: usize, } impl Default for CardDrawPlugin { fn default() -> Self { Self { max_hand_size: 7, } } } impl Plugin for CardDrawPlugin { fn build(&self, app: &mut App) { app // Store configuration .insert_resource(CardDrawConfig { max_hand_size: self.max_hand_size, }) // Register events .add_event::<CardDrawEvent>() .add_event::<DrawReplacementEvent>() // Add systems with appropriate ordering .add_systems(Update, ( process_draw_effects, move_cards_to_hand, check_maximum_hand_size, ).chain()); } } }
Next: Rendering
Rendering
This guide explains how rendering is implemented in Rummage using Bevy, focusing on card rendering, UI components, and visual effects.
Table of Contents
- Introduction to Bevy Rendering
- Rendering Architecture in Rummage
- Card Rendering
- UI Components
- Visual Effects
- Performance Optimization
- Best Practices
- Common Issues
Introduction to Bevy Rendering
Bevy provides a powerful, flexible rendering system that uses a render graph to define how rendering occurs. Key components of Bevy's rendering system include:
- Render pipeline: Configures how meshes, textures, and other visual elements are processed by the GPU
- Materials: Define surface properties of rendered objects
- Meshes: 3D geometry data
- Textures: Image data used for rendering
- Cameras: Define viewports into the rendered scene
- Sprites: 2D images rendered in the world
- UI nodes: User interface elements
In Bevy 0.15.x, some important changes were made to the rendering API:
Text2dBundle
,SpriteBundle
, andNodeBundle
are deprecated in favor ofText2d
,Sprite
, andNode
components- Enhanced material system
- Improved shader support
- Better handling of textures and assets
Rendering Architecture in Rummage
Rummage employs a layered rendering architecture:
- Game World Layer: Renders the battlefield, zones, and cards
- UI Overlay Layer: Renders UI elements like menus, tooltips, and dialogs
- Effect Layer: Renders visual effects and animations
The rendering is managed through several dedicated plugins:
RenderPlugin
: Core rendering setupCardRenderPlugin
: Card-specific renderingUIPlugin
: User interface renderingEffectPlugin
: Visual effects and animations
Card Rendering
Cards are the central visual element in a Magic: The Gathering game. Rummage's card rendering system handles both full cards and minimized versions.
Card Components
Card rendering uses these key components:
#![allow(unused)] fn main() { // Card visual representation #[derive(Component)] pub struct CardVisual { pub style: CardStyle, pub state: CardVisualState, } // Card visual state (tapped, highlighted, etc.) #[derive(Component)] pub enum CardVisualState { Normal, Tapped, Highlighted, Selected, Targeted, } // Card render options #[derive(Component)] pub struct CardRenderOptions { pub show_full_art: bool, pub zoom_on_hover: bool, pub animation_speed: f32, } }
Card Rendering System
The main card rendering system:
#![allow(unused)] fn main() { // Example system for updating card visuals based on game state fn update_card_visuals( mut commands: Commands, mut card_query: Query<(Entity, &Card, &mut Transform, Option<&CardVisual>)>, card_state_query: Query<&CardGameState>, asset_server: Res<AssetServer>, ) { for (entity, card, mut transform, visual) in &mut card_query { // Get card state (tapped, etc.) let card_state = card_state_query.get(entity).unwrap_or(&CardGameState::Normal); // If no visual component or state changed, update visual if visual.is_none() || visual.unwrap().state != card_state.into() { // Load card texture let texture = asset_server.load(&format!("cards/{}.png", card.id)); // Update or add visual components commands.entity(entity).insert(( // In Bevy 0.15, we use the Sprite component directly, not SpriteBundle Sprite { custom_size: Some(Vec2::new(CARD_WIDTH, CARD_HEIGHT)), ..default() }, // Add texture texture, // Add visual state CardVisual { style: CardStyle::Standard, state: card_state.into(), }, )); // Update transform based on state (e.g., rotate if tapped) if matches!(card_state, CardGameState::Tapped) { transform.rotation = Quat::from_rotation_z(std::f32::consts::FRAC_PI_2); } else { transform.rotation = Quat::IDENTITY; } } } } }
Card Layout
Cards are laid out using a custom layout system that handles positioning, stacking, and organization:
#![allow(unused)] fn main() { fn position_battlefield_cards( mut card_query: Query<(Entity, &BattlefieldCard, &mut Transform)>, battlefield_query: Query<&Battlefield>, ) { if let Ok(battlefield) = battlefield_query.get_single() { let mut positions = calculate_card_positions(battlefield); for (entity, battlefield_card, mut transform) in &mut card_query { if let Some(position) = positions.get(&battlefield_card.position) { transform.translation = Vec3::new(position.x, position.y, battlefield_card.layer as f32); } } } } }
UI Components
Rummage uses Bevy's UI system for menus, dialogs, and game interface elements.
UI Structure
The UI is organized hierarchically:
- Main UI root
- Game UI (playmat, zones, etc.)
- Player areas
- Stack visualization
- Phase indicator
- Menu UI (game menu, settings, etc.)
- Dialog UI (modal dialogs)
- Tooltip UI (card info, ability info)
- Game UI (playmat, zones, etc.)
UI Components in Bevy 0.15
In Bevy 0.15, UI components use the new approach:
#![allow(unused)] fn main() { // Create a UI node commands.spawn(( // Node component instead of NodeBundle Node { style: Style { width: Val::Percent(100.0), height: Val::Percent(100.0), ..default() }, background_color: Color::rgba(0.1, 0.1, 0.1, 0.8), ..default() }, // Other components UIRoot, )); // Create text commands.spawn(( // Text component instead of TextBundle Text { sections: vec![TextSection { value: "Player 1".to_string(), style: TextStyle { font: font.clone(), font_size: 24.0, color: Color::WHITE, }, }], alignment: TextAlignment::Center, ..default() }, // Style info Style { position_type: PositionType::Absolute, top: Val::Px(10.0), left: Val::Px(10.0), ..default() }, // Additional components PlayerNameLabel(1), )); }
Dynamic UI Updates
UI elements are updated based on game state:
#![allow(unused)] fn main() { fn update_phase_indicator( mut text_query: Query<&mut Text, With<PhaseIndicator>>, game_state: Res<GameState>, ) { if let Ok(mut text) = text_query.get_single_mut() { text.sections[0].value = format!("Phase: {}", game_state.current_phase.to_string()); } } }
Visual Effects
Rummage includes a variety of visual effects to enhance the gaming experience.
Effect Components
Effects use dedicated components:
#![allow(unused)] fn main() { // Visual effect component #[derive(Component)] pub struct VisualEffect { pub effect_type: EffectType, pub duration: Timer, pub intensity: f32, } // Effect types pub enum EffectType { CardGlow, Explosion, Sparkle, DamageFlash, HealingGlow, } }
Effect Systems
Effects are processed by dedicated systems:
#![allow(unused)] fn main() { fn process_visual_effects( mut commands: Commands, time: Res<Time>, mut effect_query: Query<(Entity, &mut VisualEffect, &mut Sprite)>, ) { for (entity, mut effect, mut sprite) in &mut effect_query { // Update effect timer effect.duration.tick(time.delta()); // Calculate effect progress (0.0 to 1.0) let progress = effect.duration.percent(); // Apply effect based on type match effect.effect_type { EffectType::CardGlow => { // Modify sprite color based on progress let intensity = (progress * std::f32::consts::PI).sin() * effect.intensity; sprite.color = sprite.color.with_a(0.5 + intensity * 0.5); }, // Handle other effect types // ... } // Remove completed effects if effect.duration.finished() { commands.entity(entity).remove::<VisualEffect>(); // Reset sprite to normal sprite.color = sprite.color.with_a(1.0); } } } }
Performance Optimization
Rendering can be resource-intensive, especially with many cards and effects. Rummage includes several optimizations:
Culling
Objects outside the view are culled to reduce rendering load:
#![allow(unused)] fn main() { fn cull_distant_cards( mut commands: Commands, camera_query: Query<(&Camera, &GlobalTransform)>, card_query: Query<(Entity, &GlobalTransform), With<Card>>, ) { if let Ok((camera, camera_transform)) = camera_query.get_single() { let camera_pos = camera_transform.translation().truncate(); for (entity, transform) in &card_query { let distance = camera_pos.distance(transform.translation().truncate()); // If card is too far away, disable its rendering if distance > MAX_CARD_RENDER_DISTANCE { commands.entity(entity).insert(Visibility::Hidden); } else { commands.entity(entity).insert(Visibility::Visible); } } } } }
Level of Detail
Cards far from the camera use simplified rendering:
#![allow(unused)] fn main() { fn adjust_card_detail( mut commands: Commands, camera_query: Query<(&Camera, &GlobalTransform)>, card_query: Query<(Entity, &GlobalTransform, &CardVisual)>, ) { if let Ok((camera, camera_transform)) = camera_query.get_single() { let camera_pos = camera_transform.translation().truncate(); for (entity, transform, visual) in &card_query { let distance = camera_pos.distance(transform.translation().truncate()); // Adjust detail level based on distance let detail_level = if distance < CLOSE_DETAIL_THRESHOLD { CardDetailLevel::High } else if distance < MEDIUM_DETAIL_THRESHOLD { CardDetailLevel::Medium } else { CardDetailLevel::Low }; // Update detail level if changed if visual.detail_level != detail_level { commands.entity(entity).insert(CardDetailLevel(detail_level)); } } } } }
Batching
Similar rendering operations are batched to reduce draw calls:
#![allow(unused)] fn main() { fn setup_material_batching( mut render_app: ResMut<App>, render_device: Res<RenderDevice>, ) { // Set up batched materials for cards with similar properties render_app.insert_resource(CardBatchingOptions { max_batch_size: 64, use_instancing: true, }); } }
Best Practices
When working with rendering in Rummage, follow these best practices:
Asset Management
- Preload assets: Use asset preprocessing to load common textures early
- Texture atlases: Group related textures in atlases to reduce binding changes
- Asset handles: Reuse asset handles instead of loading the same texture multiple times
Rendering Organization
- Separation of concerns: Keep rendering logic separate from game logic
- Component-based approach: Use components to define visual properties
- System organization: Group related rendering systems together
UI Design
- Responsive layouts: Design UI that adapts to different screen sizes
- Consistent styling: Use consistent colors, fonts, and spacing
- Performance awareness: Minimize UI elements and updates for better performance
Common Issues
Multiple Camera Issue
When using multiple cameras, queries might return multiple entities:
Problem:
#![allow(unused)] fn main() { // This will panic if there are multiple cameras let (camera, transform) = camera_query.single(); }
Solution:
#![allow(unused)] fn main() { // Use a marker component to identify the main camera #[derive(Component)] struct MainCamera; // Then query with the marker let (camera, transform) = camera_query.get_single().unwrap_or_else(|_| { panic!("Expected exactly one main camera") }); }
Z-Fighting
When cards or UI elements overlap, they might flicker due to z-fighting:
Problem:
#![allow(unused)] fn main() { // Cards at the same z position transform.translation = Vec3::new(x, y, 0.0); }
Solution:
#![allow(unused)] fn main() { // Assign incrementing z values transform.translation = Vec3::new(x, y, layer_index as f32 * 0.01); }
Texture Loading Errors
Missing textures can cause rendering issues:
Problem:
#![allow(unused)] fn main() { // No error handling for missing textures let texture = asset_server.load(&format!("cards/{}.png", card.id)); }
Solution:
#![allow(unused)] fn main() { // Use a fallback texture let texture_path = format!("cards/{}.png", card.id); let texture_handle = asset_server.load(&texture_path); // Set up a system to check for load errors fn check_texture_loading( mut events: EventReader<AssetEvent<Image>>, mut card_query: Query<(&CardIdentifier, &Handle<Image>, &mut Visibility)>, asset_server: Res<AssetServer>, ) { for event in events.iter() { if let AssetEvent::LoadFailed(handle) = event { // Find cards with the failed texture and use fallback for (card_id, image_handle, mut visibility) in &mut card_query { if image_handle == handle { // Load default texture instead commands.entity(entity).insert(asset_server.load("cards/default.png")); } } } } } }
For questions or assistance with rendering in Rummage, please contact the development team.
Camera Management
This guide explains camera management in Rummage using Bevy, with a focus on best practices for handling multiple cameras and common camera-related issues.
Table of Contents
- Introduction to Cameras in Bevy
- Camera Architecture in Rummage
- Setting Up Cameras
- Accessing Camera Data
- Camera Controls
- Multiple Camera Management
- Camera Projection
- Common Camera Issues
Introduction to Cameras in Bevy
Cameras in Bevy define how the game world is viewed. They determine what is rendered and from what perspective. In Bevy, cameras are entities with camera-related components:
- Camera: The core camera component that defines rendering properties
- GlobalTransform: Position and orientation of the camera in world space
- Projection: Orthographic or perspective projection settings
- CameraRenderGraph: Defines what render graph the camera uses
In Bevy 0.15.x, cameras have been improved with better control over rendering order, layers, and viewport settings.
Camera Architecture in Rummage
Rummage uses a multi-camera setup to handle different views of the game:
- Main Game Camera: An orthographic camera that views the game board
- UI Camera: A specialized camera for UI elements
- Hand Camera: A dedicated camera for viewing the player's hand
- Detail Camera: A camera for viewing card details up close
Each camera is assigned specific render layers to control what they render:
#![allow(unused)] fn main() { // Render layers in Rummage #[derive(Copy, Clone, Debug, Default, Component, Reflect)] pub enum RenderLayer { #[default] Game = 0, // Game elements (battlefield, etc.) UI = 1, // UI elements Hand = 2, // Hand cards CardDetail = 3, // Card detail view } }
Setting Up Cameras
Here's how cameras are set up in Rummage:
#![allow(unused)] fn main() { fn setup_cameras(mut commands: Commands) { // Main game camera (orthographic, top-down view) commands.spawn(( Camera3dBundle { transform: Transform::from_translation(Vec3::new(0.0, 10.0, 0.0)) .looking_at(Vec3::ZERO, Vec3::Z), projection: OrthographicProjection { scale: 3.0, ..default() } .into(), ..default() }, // Important! Mark this as the main camera MainCamera, // Specify what this camera renders RenderLayers::from_layers(&[RenderLayer::Game as u8]), )); // UI camera commands.spawn(( Camera2dBundle { camera: Camera { // UI camera renders after main camera order: 1, ..default() }, ..default() }, UiCamera, RenderLayers::from_layers(&[RenderLayer::UI as u8]), )); // Additional cameras as needed... } }
Accessing Camera Data
The most important practice when working with cameras in a multi-camera system is to use markers and filtered queries. This prevents the dreaded "MultipleEntities" panic that occurs when using single()
or single_mut()
with multiple cameras:
#![allow(unused)] fn main() { // Camera marker components #[derive(Component)] pub struct MainCamera; #[derive(Component)] pub struct UiCamera; #[derive(Component)] pub struct HandCamera; // Correctly accessing a specific camera fn process_main_camera( // Filter the query to only get the main camera main_camera_query: Query<(&Camera, &GlobalTransform), With<MainCamera>>, ) { // Use get_single() instead of single() for better error handling if let Ok((camera, transform)) = main_camera_query.get_single() { // Now we can safely work with the camera data let camera_position = transform.translation(); // ... } } }
Camera Controls
Rummage implements several camera control systems:
Pan and Zoom
#![allow(unused)] fn main() { fn camera_pan_system( mut camera_query: Query<&mut Transform, With<MainCamera>>, input: Res<Input<MouseButton>>, mut motion_events: EventReader<MouseMotion>, ) { if input.pressed(MouseButton::Middle) { let mut camera_transform = match camera_query.get_single_mut() { Ok(transform) => transform, Err(_) => return, // Safely handle the error }; for event in motion_events.iter() { let delta = event.delta; // Pan the camera based on mouse movement camera_transform.translation.x -= delta.x * PAN_SPEED; camera_transform.translation.y += delta.y * PAN_SPEED; } } } fn camera_zoom_system( mut camera_query: Query<(&mut OrthographicProjection, &mut Transform), With<MainCamera>>, mut scroll_events: EventReader<MouseWheel>, ) { if let Ok((mut projection, mut transform)) = camera_query.get_single_mut() { for event in scroll_events.iter() { // Zoom based on scroll direction projection.scale -= event.y * ZOOM_SPEED; // Clamp to reasonable values projection.scale = projection.scale.clamp(MIN_ZOOM, MAX_ZOOM); } } } }
Camera Transitions
For smooth transitions between camera views:
#![allow(unused)] fn main() { #[derive(Component)] pub struct CameraTransition { pub target_position: Vec3, pub target_rotation: Quat, pub duration: f32, pub timer: Timer, } fn camera_transition_system( time: Res<Time>, mut commands: Commands, mut camera_query: Query<(Entity, &mut Transform, &mut CameraTransition)>, ) { for (entity, mut transform, mut transition) in &mut camera_query { transition.timer.tick(time.delta()); let progress = transition.timer.percent(); // Interpolate position and rotation transform.translation = transform.translation .lerp(transition.target_position, progress); transform.rotation = transform.rotation .slerp(transition.target_rotation, progress); // Remove the transition component when complete if transition.timer.finished() { commands.entity(entity).remove::<CameraTransition>(); } } } }
Multiple Camera Management
When working with multiple cameras, follow these guidelines:
- Use marker components: Always attach marker components to differentiate cameras
- Filtered queries: Use query filters to target specific cameras
- Render layers: Assign render layers to control what each camera sees
- Render order: Set camera order to control rendering sequence
- Error handling: Use
get_single()
with error handling instead ofsingle()
This system coordinates multiple cameras:
#![allow(unused)] fn main() { fn coordinate_cameras( card_detail_state: Res<State<CardDetailState>>, mut main_camera_query: Query<&mut Camera, (With<MainCamera>, Without<UiCamera>)>, mut ui_camera_query: Query<&mut Camera, With<UiCamera>>, ) { // Get cameras with proper error handling let mut main_camera = match main_camera_query.get_single_mut() { Ok(camera) => camera, Err(_) => return, }; let mut ui_camera = match ui_camera_query.get_single_mut() { Ok(camera) => camera, Err(_) => return, }; // Adjust cameras based on game state match card_detail_state.get() { CardDetailState::Viewing => { // While viewing a card detail, disable the main camera main_camera.is_active = false; } CardDetailState::None => { // When not viewing details, enable the main camera main_camera.is_active = true; } } // UI camera is always active ui_camera.is_active = true; } }
Camera Projection
Rummage uses orthographic projection for the main game camera, as it provides a clearer view of the card game board:
#![allow(unused)] fn main() { fn setup_orthographic_camera(mut commands: Commands) { commands.spawn(( Camera3dBundle { projection: OrthographicProjection { scale: 3.0, scaling_mode: ScalingMode::FixedVertical(2.0), near: -1000.0, far: 1000.0, ..default() } .into(), transform: Transform::from_translation(Vec3::new(0.0, 10.0, 0.0)) .looking_at(Vec3::ZERO, Vec3::Z), ..default() }, MainCamera, )); } }
For specialized views like card details, a perspective camera might be used:
#![allow(unused)] fn main() { fn setup_perspective_camera(mut commands: Commands) { commands.spawn(( Camera3dBundle { projection: PerspectiveProjection { fov: std::f32::consts::PI / 4.0, near: 0.1, far: 100.0, aspect_ratio: 1.0, } .into(), transform: Transform::from_translation(Vec3::new(0.0, 0.0, 10.0)) .looking_at(Vec3::ZERO, Vec3::Y), ..default() }, DetailCamera, )); } }
Common Camera Issues
MultipleEntities Error
The most common camera-related error is the "MultipleEntities" panic, which occurs when multiple entities match a camera query that expects a single result:
Problem:
#![allow(unused)] fn main() { fn problematic_camera_system( camera_query: Query<(&Camera, &GlobalTransform)>, ) { // This will panic if there are multiple cameras let (camera, transform) = camera_query.single(); // ... } }
Solution:
#![allow(unused)] fn main() { // Add a marker component to your cameras #[derive(Component)] struct MainCamera; // Then query with the marker fn fixed_camera_system( camera_query: Query<(&Camera, &GlobalTransform), With<MainCamera>>, ) { if let Ok((camera, transform)) = camera_query.get_single() { // Now we only get the camera with the MainCamera marker } else { // Handle the error case gracefully warn!("Expected exactly one main camera"); } } }
Incorrect View Frustum
If objects aren't visible when they should be, check the camera's near and far planes:
Problem:
#![allow(unused)] fn main() { // Objects might be outside the camera's view frustum OrthographicProjection { near: 0.1, far: 100.0, // ... } }
Solution:
#![allow(unused)] fn main() { // Use more generous near/far values for card games OrthographicProjection { near: -1000.0, // Allow objects "behind" the camera in orthographic view far: 1000.0, // See objects far away // ... } }
Camera Depth Issues
Objects appearing in unexpected order:
Problem:
#![allow(unused)] fn main() { // Z-fighting or depth order issues transform.translation = Vec3::new(x, y, 0.0); }
Solution:
#![allow(unused)] fn main() { // Use the z-coordinate for explicit depth ordering transform.translation = Vec3::new(x, y, layer * 0.1); // Or adjust the camera's transform camera_transform.translation = Vec3::new(0.0, 0.0, z_distance); camera_transform.look_at(Vec3::ZERO, Vec3::Y); }
Next: Handling Game State
Random Number Generation with bevy_rand
This guide explains how to implement deterministic random number generation in Rummage using bevy_rand
and bevy_prng
, with a focus on entity-attached RNGs for networked state synchronization.
Table of Contents
- Overview
- Setup and Configuration
- Entity-Attached RNGs
- Networked State Synchronization
- Testing and Debugging
- Implementation Patterns
- Performance Considerations
Overview
In Rummage, deterministic random number generation is critical for:
- Networked Gameplay: Ensuring all clients produce identical results when processing the same game actions
- Replay Functionality: Allowing game sessions to be accurately replayed
- Testing: Creating reproducible test scenarios
The bevy_rand
ecosystem provides the tools we need to implement deterministic RNG that can be:
- Attached to specific entities
- Serialized/deserialized for network transmission
- Rolled back and restored for state reconciliation
Setup and Configuration
Dependencies
In your Cargo.toml
, include the following dependencies:
[dependencies]
bevy_rand = { git = "https://github.com/Bluefinger/bevy_rand", branch = "main", features = ["wyrand", "experimental"] }
bevy_prng = { git = "https://github.com/Bluefinger/bevy_rand", branch = "main", features = ["wyrand"] }
The wyrand
feature specifies the WyRand algorithm, which provides a good balance of performance and quality. The experimental
feature enables bevy_rand
's entity-attached RNG functionality.
Plugin Registration
Set up the RNG system in your app:
use bevy::prelude::*; use bevy_prng::WyRand; use bevy_rand::prelude::*; fn main() { App::new() // Add the entropy plugin with WyRand algorithm .add_plugins(EntropyPlugin::<WyRand>::default()) // Your other plugins... .run(); }
Seeding for Determinism
For deterministic behavior, seed the global RNG at the start of your game:
#![allow(unused)] fn main() { fn setup_deterministic_rng(mut global_entropy: ResMut<GlobalEntropy<WyRand>>) { // Use a fixed seed for testing or derive from game parameters let seed = 12345u64; global_entropy.seed_from_u64(seed); } }
For multiplayer games, the server should generate the seed and communicate it to clients during game initialization.
Entity-Attached RNGs
Core Concept
Entity-attached RNGs allow different game entities (players, decks, etc.) to have their own independent but deterministic random number generators. This is critical for:
- Isolation: Each entity's randomization is independent of others
- Reproducibility: Given the same initial state, entities will produce the same sequence of random numbers
- State Management: Entity RNG state can be saved, restored, and synchronized
Creating Entity-Attached RNGs
#![allow(unused)] fn main() { use bevy::prelude::*; use bevy_prng::WyRand; use bevy_rand::prelude::*; // Component to hold an entity's RNG #[derive(Component)] struct EntityRng(Entropy<WyRand>); // System to set up RNGs for entities that need them fn setup_entity_rngs( mut commands: Commands, entities: Query<Entity, (With<Player>, Without<EntityRng>)>, mut global_entropy: ResMut<GlobalEntropy<WyRand>>, ) { for entity in &entities { // Create a new RNG forked from the global entropy source let entity_rng = global_entropy.fork_rng(); // Attach it to the entity commands.entity(entity).insert(EntityRng(entity_rng)); } } }
Using Entity-Attached RNGs
To use an entity's RNG:
#![allow(unused)] fn main() { fn shuffle_player_deck( mut players: Query<(&Player, &mut EntityRng)>, mut decks: Query<&mut Deck>, ) { for (player, mut entity_rng) in &mut players { if let Ok(mut deck) = decks.get_mut(player.deck_entity) { // Use the player's RNG to shuffle their deck deck.shuffle_with_rng(&mut entity_rng.0); } } } }
Networked State Synchronization
Serializing RNG State
For network transmission, RNG state must be serialized:
#![allow(unused)] fn main() { // Resource to track RNG state for network sync #[derive(Resource)] struct NetworkedRngState { // Global RNG state global_state: Vec<u8>, // Player entity RNG states mapped by entity ID entity_states: HashMap<Entity, Vec<u8>>, // Last sync timestamp last_sync: f32, } // System to capture RNG states for replication fn capture_rng_states( global_entropy: Res<GlobalEntropy<WyRand>>, entity_rngs: Query<(Entity, &EntityRng)>, mut networked_state: ResMut<NetworkedRngState>, time: Res<Time>, ) { // Only sync periodically to reduce network traffic if time.elapsed_seconds() - networked_state.last_sync < 5.0 { return; } // Capture global RNG state if let Ok(serialized) = global_entropy.try_serialize_state() { networked_state.global_state = serialized; } // Capture entity RNG states for (entity, entity_rng) in &entity_rngs { if let Ok(serialized) = entity_rng.0.try_serialize_state() { networked_state.entity_states.insert(entity, serialized); } } networked_state.last_sync = time.elapsed_seconds(); } }
Transmitting RNG State
Use Bevy Replicon to efficiently sync RNG state between server and clients:
#![allow(unused)] fn main() { use bevy_replicon::prelude::*; // Server-authoritative replication #[derive(Component, Serialize, Deserialize, Clone)] struct ReplicatedRngState { state: Vec<u8>, last_updated: f32, } // System to update replication components fn update_rng_replication( mut commands: Commands, players: Query<(Entity, &EntityRng)>, time: Res<Time>, ) { for (entity, entity_rng) in &players { if let Ok(serialized) = entity_rng.0.try_serialize_state() { commands.entity(entity).insert(ReplicatedRngState { state: serialized, last_updated: time.elapsed_seconds(), }); } } } }
Restoring RNG State on Clients
#![allow(unused)] fn main() { // System to apply RNG state updates from server fn apply_rng_state_updates( mut players: Query<(Entity, &ReplicatedRngState, &mut EntityRng)>, mut applied_states: Local<HashMap<Entity, f32>>, ) { for (entity, replicated_state, mut entity_rng) in &mut players { // Check if this is a newer state than what we've already applied if !applied_states.contains_key(&entity) || applied_states[&entity] < replicated_state.last_updated { // Apply the updated state if let Ok(()) = entity_rng.0.deserialize_state(&replicated_state.state) { applied_states.insert(entity, replicated_state.last_updated); } } } } }
Testing and Debugging
Verifying Determinism
To verify RNG determinism, create a test that:
- Seeds multiple RNGs with the same seed
- Generates a sequence of random values from each
- Compares the sequences for equality
#![allow(unused)] fn main() { #[test] fn test_rng_determinism() { // Create two separate RNGs with the same seed let seed = 12345u64; let mut rng1 = WyRand::seed_from_u64(seed); let mut rng2 = WyRand::seed_from_u64(seed); // Generate sequences from both RNGs let sequence1: Vec<u32> = (0..100).map(|_| rng1.gen_range(0..1000)).collect(); let sequence2: Vec<u32> = (0..100).map(|_| rng2.gen_range(0..1000)).collect(); // Verify sequences are identical assert_eq!(sequence1, sequence2, "RNG sequences should be identical with the same seed"); } }
Debugging Network Desynchronization
When RNG state gets out of sync across the network:
-
Add Logging: Log RNG states and the random values they generate
#![allow(unused)] fn main() { info!("Entity {}: RNG state hash: {:?}, Next value: {}", entity, hash_rng_state(&entity_rng.0), entity_rng.0.gen_range(0..100)); }
-
State Comparison: Compare serialized RNG states between server and clients
#![allow(unused)] fn main() { fn debug_rng_states( server_states: &HashMap<Entity, Vec<u8>>, client_states: &HashMap<Entity, Vec<u8>>, ) { for (entity, server_state) in server_states { if let Some(client_state) = client_states.get(entity) { if server_state != client_state { warn!("RNG state mismatch for entity {}", entity); } } } } }
-
Event Logging: Track every action that uses RNG to pinpoint where divergence occurs
Implementation Patterns
Deck Shuffling
For card games like MTG, deck shuffling must be consistent across the network:
#![allow(unused)] fn main() { // Component for a deck of cards #[derive(Component)] struct Deck { cards: Vec<Entity>, } // System to shuffle a deck using entity-attached RNG fn shuffle_deck( mut decks: Query<&mut Deck>, deck_owner: Query<&DeckOwner>, mut players: Query<&mut EntityRng>, mut shuffle_events: EventReader<ShuffleDeckEvent>, ) { for event in shuffle_events.iter() { // Get the deck if let Ok(mut deck) = decks.get_mut(event.deck_entity) { // Find the deck owner if let Ok(owner) = deck_owner.get(event.deck_entity) { // Get the owner's RNG if let Ok(mut entity_rng) = players.get_mut(owner.0) { // Use Fisher-Yates shuffle with the owner's RNG let mut cards = deck.cards.clone(); for i in (1..cards.len()).rev() { let j = entity_rng.0.gen_range(0..=i); cards.swap(i, j); } deck.cards = cards; } } } } } }
Random Card Selection
For abilities that select random targets:
#![allow(unused)] fn main() { fn random_target_selection( mut commands: Commands, mut ability_events: EventReader<AbilityActivatedEvent>, players: Query<&EntityRng>, targets: Query<Entity, With<Targetable>>, ) { for event in ability_events.iter() { if event.ability_type == AbilityType::RandomTarget { // Get the entity's RNG if let Ok(entity_rng) = players.get(event.player_entity) { let target_entities: Vec<Entity> = targets.iter().collect(); if !target_entities.is_empty() { // Select a random target using the entity's RNG let random_index = entity_rng.0.gen_range(0..target_entities.len()); let selected_target = target_entities[random_index]; // Apply the ability effect to the selected target commands.entity(selected_target).insert(AbilityEffect { source: event.player_entity, effect_type: event.effect_type, }); } } } } } }
Performance Considerations
Minimizing RNG Operations
Random number generation can be computationally expensive:
-
Cache Random Results: Generate batches of random values when possible
#![allow(unused)] fn main() { // Generate and cache random values for _ in 0..10 { let value = entity_rng.0.gen_range(0..100); cached_values.push(value); } }
-
Optimize RNG Distribution: Use the most efficient distribution for your needs
#![allow(unused)] fn main() { // For uniform integer distributions, use gen_range let value = rng.gen_range(0..100); // For weighted choices, use a weight-optimized approach let choices = vec![(option1, 10), (option2, 5), (option3, 1)]; let total_weight: u32 = choices.iter().map(|(_, w)| w).sum(); let mut rng_value = rng.gen_range(0..total_weight); for (option, weight) in choices { if rng_value < *weight { selected = option; break; } rng_value -= *weight; } }
-
Schedule RNG Operations: Spread intensive RNG work across frames
State Synchronization Frequency
Synchronize RNG state efficiently:
- Event-Driven Updates: Sync after significant random events rather than on a timer
- Delta Compression: Only send changes to RNG state
- Prioritize Critical Entities: Sync more frequently for gameplay-critical entities
By following these guidelines, you can create a robust, deterministic random number generation system that works reliably across network boundaries in your Bevy application.
Bevy 0.15.x API Deprecations Guide
This guide documents the deprecated APIs in Bevy 0.15.x and their recommended replacements for use in the Rummage project.
Bundle Deprecations
Bevy 0.15.x has deprecated many bundle types in favor of a more streamlined component approach.
Rendering Bundles
Deprecated | Replacement |
---|---|
Text2dBundle | Text2d component |
SpriteBundle | Sprite component |
ImageBundle | Image component |
Example:
#![allow(unused)] fn main() { // ❌ Deprecated approach commands.spawn(SpriteBundle { texture: asset_server.load("path/to/sprite.png"), transform: Transform::from_translation(Vec3::new(0.0, 0.0, 0.0)), ..default() }); // ✅ New approach commands.spawn(( Sprite::default(), asset_server.load::<Image>("path/to/sprite.png"), Transform::from_translation(Vec3::new(0.0, 0.0, 0.0)), )); }
UI Bundles
Deprecated | Replacement |
---|---|
NodeBundle | Node component |
ButtonBundle | Combine Button with other components |
TextBundle | Combine Text and other components |
Example:
#![allow(unused)] fn main() { // ❌ Deprecated approach commands.spawn(NodeBundle { style: Style { width: Val::Px(200.0), height: Val::Px(100.0), ..default() }, background_color: Color::WHITE.into(), ..default() }); // ✅ New approach commands.spawn(( Node { // Node properties }, Style { width: Val::Px(200.0), height: Val::Px(100.0), ..default() }, BackgroundColor(Color::WHITE), )); }
Camera Bundles
Deprecated | Replacement |
---|---|
Camera2dBundle | Camera2d component |
Camera3dBundle | Camera3d component |
Example:
#![allow(unused)] fn main() { // ❌ Deprecated approach commands.spawn(Camera2dBundle::default()); // ✅ New approach commands.spawn(Camera2d::default()); }
Transform Bundles
Deprecated | Replacement |
---|---|
SpatialBundle | Combine Transform and Visibility |
TransformBundle | Transform component |
GlobalTransform (manual insertion) | Just insert Transform |
Example:
#![allow(unused)] fn main() { // ❌ Deprecated approach commands.spawn(SpatialBundle::default()); // ✅ New approach commands.spawn(( Transform::default(), Visibility::default(), )); }
Required Component Pattern
Bevy 0.15.x uses a "required component" pattern where inserting certain components will automatically insert their prerequisites. This means you no longer need to explicitly add all dependencies.
Examples of Required Component Pattern
#![allow(unused)] fn main() { // Camera2d will automatically add Camera, Transform, and other required components commands.spawn(Camera2d::default()); // Transform will automatically add GlobalTransform commands.spawn(Transform::default()); // Sprite will automatically add TextureAtlas related components commands.spawn(Sprite::default()); }
Event API Changes
Deprecated | Replacement |
---|---|
Events::get_reader() | Events::get_cursor() |
ManualEventReader | EventCursor |
Example:
#![allow(unused)] fn main() { // ❌ Deprecated approach let mut reader = events.get_reader(); for event in reader.read(&events) { // Handle event } // ✅ New approach let mut cursor = events.get_cursor(); for event in cursor.read(&events) { // Handle event } }
Entity Access Changes
Deprecated | Replacement |
---|---|
Commands.get_or_spawn() | Commands.entity() or Commands.spawn() |
World.get_or_spawn() | World.entity() or World.spawn() |
Example:
#![allow(unused)] fn main() { // ❌ Deprecated approach commands.get_or_spawn(entity).insert(MyComponent); // ✅ New approach if world.contains_entity(entity) { commands.entity(entity).insert(MyComponent); } else { commands.spawn((entity, MyComponent)); } }
UI Node Access Changes
Deprecated | Replacement |
---|---|
Node::logical_rect() | Rect::from_center_size with translation and node size |
Node::physical_rect() | Rect::from_center_size with translation and node size |
Example:
#![allow(unused)] fn main() { // ❌ Deprecated approach let rect = node.logical_rect(transform); // ✅ New approach let rect = Rect::from_center_size( transform.translation().truncate(), node.size(), ); }
Window and Input Changes
Deprecated | Replacement |
---|---|
CursorIcon field in Window | CursorIcon component on window entity |
Window.add_*_listener methods | Use event systems |
Example:
#![allow(unused)] fn main() { // ❌ Deprecated approach window.cursor_icon = CursorIcon::Hand; // ✅ New approach commands.entity(window_entity).insert(CursorIcon::Hand); }
Best Practices
- Check Compiler Warnings: Always check for compiler warnings after making changes, as they will indicate usage of deprecated APIs.
- Use Component Approach: Prefer the component-based approach over bundles.
- Required Components: Leverage Bevy's automatic insertion of required components.
- Run Tests: Run tests frequently to ensure compatibility.
Troubleshooting
If you encounter issues after replacing deprecated APIs:
- Check Component Dependencies: Some components may have implicit dependencies that need to be explicitly added.
- Verify Insertion Order: In some cases, the order of component insertion matters.
- Update Queries: Update your queries to match the new component structure.
- Check Bevy Changelog: Refer to the Bevy changelog for detailed API changes.
Persistent Storage with bevy_persistent
This guide provides an overview of using bevy_persistent
for data persistence in Rummage.
Introduction
bevy_persistent
is a Bevy crate that makes it easy to save and load data across application sessions. It enables robust persistence for:
- User settings
- Game saves
- Deck collections
- Game state snapshots
- Player profiles
- Achievement data
Getting Started
Adding the Dependency
First, add bevy_persistent
to your Cargo.toml
:
[dependencies]
bevy_persistent = "0.4.0" # Use the latest compatible version
Basic Usage
The basic pattern for using bevy_persistent
is:
use bevy::prelude::*; use bevy_persistent::prelude::*; use serde::{Deserialize, Serialize}; // Define a persistent resource #[derive(Resource, Serialize, Deserialize, Default)] struct GameSettings { volume: f32, fullscreen: bool, resolution: (u32, u32), } fn main() { App::new() .add_plugins(DefaultPlugins) // Initialize persistent resource .insert_resource( Persistent::<GameSettings>::builder() .name("settings") .format(StorageFormat::Ron) .path("user://settings.ron") .default(GameSettings { volume: 0.5, fullscreen: false, resolution: (1920, 1080), }) .build() ) .add_systems(Startup, load_settings) .add_systems(Update, save_settings_on_change) .run(); } // Load settings at startup fn load_settings(mut settings: ResMut<Persistent<GameSettings>>) { if let Err(err) = settings.load() { error!("Failed to load settings: {}", err); } else { info!("Settings loaded successfully"); } } // Save settings when they change fn save_settings_on_change(settings: Res<Persistent<GameSettings>>) { if settings.is_changed() { if let Err(err) = settings.save() { error!("Failed to save settings: {}", err); } else { info!("Settings saved successfully"); } } }
Key Features
Builder Pattern
The library uses a builder pattern for constructing persistent resources:
#![allow(unused)] fn main() { Persistent::<T>::builder() .name("resource_name") // Human-readable name .format(StorageFormat::Ron) // Serialization format .path("user://file.ron") // Storage path .default(T::default()) // Default value .build() }
Storage Formats
bevy_persistent
supports multiple storage formats:
- Ron: Human-readable Rusty Object Notation (good for configs)
- Json: Standard JSON format (good for interoperability)
- Bincode: Efficient binary format (good for large data)
- Toml: Config-friendly format (good for settings)
- Yaml: Human-readable structured format
Storage Paths
Paths can use special prefixes:
user://
: User-specific data directoryconfig://
: Configuration directorycache://
: Cache directoryassets://
: Assets directory
For example:
#![allow(unused)] fn main() { .path("user://saves/profile1.save") }
Hot Reloading
During development, you can enable hot reloading of persistent resources:
#![allow(unused)] fn main() { fn hot_reload_system(mut settings: ResMut<Persistent<GameSettings>>) { if settings.was_modified_on_disk() { if let Err(err) = settings.load() { error!("Failed to hot reload settings: {}", err); } else { info!("Hot reloaded settings from disk"); } } } }
Error Handling
The library provides comprehensive error handling:
#![allow(unused)] fn main() { match settings.load() { Ok(()) => info!("Loaded successfully"), Err(PersistentError::Io(err)) => error!("I/O error: {}", err), Err(PersistentError::Deserialize(err)) => error!("Deserialization error: {}", err), Err(err) => error!("Other error: {}", err), } }
Best Practices
Atomicity
To ensure atomic updates (all-or-nothing):
#![allow(unused)] fn main() { // Make multiple changes in a transaction-like manner fn update_settings(mut settings: ResMut<Persistent<GameSettings>>) { // Make changes settings.volume = 0.8; settings.fullscreen = true; settings.resolution = (3840, 2160); // Save all changes at once if let Err(err) = settings.save() { error!("Failed to save settings: {}", err); // Optionally revert changes on error } } }
Versioning
For schema changes, use serde's versioning support:
#![allow(unused)] fn main() { #[derive(Resource, Serialize, Deserialize)] struct GameSettings { // Add version field for schema migration #[serde(default = "default_version")] version: u32, // Original fields volume: f32, fullscreen: bool, // New fields with defaults #[serde(default)] resolution: (u32, u32), } fn default_version() -> u32 { 1 } }
Resource Granularity
Choose the right granularity for persistent resources:
- Too coarse: One resource for all settings makes recovery harder
- Too fine: Too many small resources increases I/O overhead
Good balance:
#![allow(unused)] fn main() { // Audio settings in one resource #[derive(Resource, Serialize, Deserialize, Default)] struct AudioSettings { /* ... */ } // Video settings in another #[derive(Resource, Serialize, Deserialize, Default)] struct VideoSettings { /* ... */ } // Controls in another #[derive(Resource, Serialize, Deserialize, Default)] struct ControlSettings { /* ... */ } }
Change Detection
For efficient saving, only save when something has actually changed:
#![allow(unused)] fn main() { fn save_if_changed( settings: Res<Persistent<GameSettings>>, time: Res<Time>, mut last_save: Local<f64>, ) { // Only check if resource changed if settings.is_changed() { let now = time.elapsed_seconds_f64(); // Don't save too frequently (debounce) if now - *last_save > 5.0 { if let Err(err) = settings.save() { error!("Failed to save: {}", err); } else { *last_save = now; } } } } }
Use Cases in Rummage
Deck Database
For the deck database, use bevy_persistent
to store user decks:
#![allow(unused)] fn main() { // See: docs/card_systems/deck_database/persistent_storage.md }
Game State Snapshots
For game state snapshots and rollback functionality:
#![allow(unused)] fn main() { // See: docs/core_systems/snapshot/bevy_persistent_integration.md }
User Settings
For user preferences and settings:
#![allow(unused)] fn main() { #[derive(Resource, Serialize, Deserialize, Default)] struct UserPreferences { username: String, card_style: CardStyle, audio_volume: f32, enable_animations: bool, enable_auto_tap: bool, enable_hints: bool, } // Initialize in plugin fn build_user_settings(app: &mut App) { let preferences = Persistent::<UserPreferences>::builder() .name("user_preferences") .format(StorageFormat::Ron) .path("user://preferences.ron") .default(UserPreferences::default()) .build(); app.insert_resource(preferences) .add_systems(Startup, load_user_preferences) .add_systems(Update, save_preferences_on_change); } }
Troubleshooting
Common Issues
- File Not Found: Check if the directory exists and has write permissions
- Serialization Errors: Make sure all fields are serializable
- Path Resolution: Use the correct path prefix for different platforms
Debugging Tips
Enable logging to debug storage issues:
#![allow(unused)] fn main() { // Enable debug logs for bevy_persistent fn setup_logging() { env_logger::Builder::from_default_env() .filter_module("bevy_persistent", log::LevelFilter::Debug) .init(); } }
Related Documentation
Testing Integration
API Reference
This section provides detailed documentation for the Rummage game engine API, including core types, components, systems, and their relationships.
Overview
The Rummage API is built around Bevy's Entity Component System (ECS) architecture, organizing game elements through components, resources, and systems. This documentation helps developers understand how these pieces fit together to implement MTG Commander gameplay.
Core Modules
The API is organized into the following core modules:
- Game Engine - Core game logic, rules implementation, and state management
- Card Systems - Card representation, effects, and interactions
- Player - Player state, resources, and interactions
- UI - Game interface and visual elements
- Networking - Multiplayer functionality and state synchronization
Game Engine APIs
The game engine implements the core MTG rules through several interconnected modules:
- Actions - Game actions and their resolution
- State - Game state tracking and transitions
- Zones - Game zones (library, hand, battlefield, etc.)
- Stack - The MTG stack and priority system
- Turns & Phases - Turn structure and phase progression
- Combat - Combat mechanics and damage resolution
- Commander - Commander-specific rules implementation
How to Use This Documentation
- Core Types - Fundamental types like
GameState
,Phase
, etc. - Component Reference - Documentation for all ECS components
- System Reference - Documentation for all ECS systems and their functions
- Resource Reference - Documentation for game resources and global state
For implementation details, see the corresponding sections in the MTG Core Rules and Commander Format documentation.
Core Types
This document provides a reference for the fundamental data types used in Rummage.
Game State Types
Phase and Step Types
#![allow(unused)] fn main() { /// The phases of a Magic: The Gathering turn #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Reflect)] pub enum Phase { /// Beginning phase Beginning, /// First main phase PreCombatMain, /// Combat phase Combat, /// Second main phase PostCombatMain, /// Ending phase Ending, } /// The steps within phases of a Magic: The Gathering turn #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Reflect)] pub enum Step { /// Beginning phase - Untap step Untap, /// Beginning phase - Upkeep step Upkeep, /// Beginning phase - Draw step Draw, /// Combat phase - Beginning of combat step BeginningOfCombat, /// Combat phase - Declare attackers step DeclareAttackers, /// Combat phase - Declare blockers step DeclareBlockers, /// Combat phase - First strike damage step (only if needed) FirstStrikeDamage, /// Combat phase - Combat damage step CombatDamage, /// Combat phase - End of combat step EndOfCombat, /// Ending phase - End step End, /// Ending phase - Cleanup step Cleanup, } }
Zone Types
#![allow(unused)] fn main() { /// Game zones in Magic: The Gathering #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Reflect)] pub enum ZoneType { /// The library (deck) zone Library, /// The hand zone Hand, /// The battlefield zone (permanents in play) Battlefield, /// The graveyard zone (discard pile) Graveyard, /// The stack zone (spells and abilities being cast/activated) Stack, /// The exile zone (removed from game) Exile, /// The command zone (for commanders, emblems, etc.) Command, /// The sideboard zone (unused in Commander) Sideboard, } }
Card Types
Card Type System
#![allow(unused)] fn main() { /// Types of Magic: The Gathering cards #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Reflect)] pub struct CardTypes { /// Card super types (Legendary, Basic, etc.) pub super_types: HashSet<String>, /// Card types (Creature, Instant, etc.) pub card_types: HashSet<String>, /// Card sub types (Human, Wizard, Equipment, etc.) pub sub_types: HashSet<String>, } impl CardTypes { /// Constant for the Creature type pub const TYPE_CREATURE: Self = Self { card_types: HashSet::from(["Creature".to_string()]), super_types: HashSet::new(), sub_types: HashSet::new(), }; /// Create a new creature type pub fn new_creature(creature_types: Vec<String>) -> Self { Self { card_types: HashSet::from(["Creature".to_string()]), super_types: HashSet::new(), sub_types: HashSet::from_iter(creature_types), } } /// Check if this is a creature pub fn is_creature(&self) -> bool { self.card_types.contains("Creature") } /// Get creature types pub fn get_creature_types(&self) -> Vec<String> { if !self.is_creature() { return Vec::new(); } self.sub_types.iter().cloned().collect() } // Similar methods for other card types } }
Card Details
#![allow(unused)] fn main() { /// Details specific to different card types #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Reflect)] pub enum CardDetails { /// Creature card details Creature(CreatureCard), /// Planeswalker card details Planeswalker { loyalty: i32 }, /// Instant card details Instant(SpellCard), /// Sorcery card details Sorcery(SpellCard), /// Enchantment card details Enchantment(EnchantmentCard), /// Artifact card details Artifact(ArtifactCard), /// Land card details Land(LandCard), /// Other card types Other, } /// Creature card details #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Reflect)] pub struct CreatureCard { /// Creature's power pub power: i32, /// Creature's toughness pub toughness: i32, /// Creature's type pub creature_type: CreatureType, } }
Mana System
Mana Types
#![allow(unused)] fn main() { /// Representation of mana in Magic: The Gathering #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Reflect)] pub struct Mana { /// Generic/colorless mana pub generic: u32, /// White mana pub white: u32, /// Blue mana pub blue: u32, /// Black mana pub black: u32, /// Red mana pub red: u32, /// Green mana pub green: u32, } impl Mana { /// Create a new Mana value pub fn new(generic: u32, white: u32, blue: u32, black: u32, red: u32, green: u32) -> Self { Self { generic, white, blue, black, red, green, } } /// Calculate the converted mana cost (total mana value) pub fn converted_mana_cost(&self) -> u32 { self.generic + self.white + self.blue + self.black + self.red + self.green } /// Get the amount of a specific color pub fn colored_mana_cost(&self, color: Color) -> u32 { match color { Color::Generic => self.generic, Color::White => self.white, Color::Blue => self.blue, Color::Black => self.black, Color::Red => self.red, Color::Green => self.green, } } } /// Color in Magic: The Gathering #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Reflect)] pub enum Color { /// Generic/colorless mana Generic, /// White mana White, /// Blue mana Blue, /// Black mana Black, /// Red mana Red, /// Green mana Green, } }
Keyword Abilities
#![allow(unused)] fn main() { /// Keyword abilities in Magic: The Gathering #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Reflect)] pub enum KeywordAbility { /// Flying Flying, /// First Strike FirstStrike, /// Double Strike DoubleStrike, /// Deathtouch Deathtouch, /// Lifelink Lifelink, /// Haste Haste, /// Hexproof Hexproof, /// Indestructible Indestructible, /// Menace Menace, /// Protection Protection, /// Reach Reach, /// Trample Trample, /// Vigilance Vigilance, // And many more... } /// Container for a card's keyword abilities #[derive(Debug, Clone, Default, Serialize, Deserialize, Reflect)] pub struct KeywordAbilities { /// Set of abilities this card has pub abilities: HashSet<KeywordAbility>, /// Values for abilities that need them (e.g., "Protection from black") pub ability_values: HashMap<KeywordAbility, String>, } impl KeywordAbilities { /// Parse keywords from rules text pub fn from_rules_text(rules_text: &str) -> Self { // Implementation that parses keywords from rules text let mut abilities = HashSet::new(); let mut ability_values = HashMap::new(); // Example parsing logic if rules_text.contains("Flying") { abilities.insert(KeywordAbility::Flying); } if rules_text.contains("First strike") { abilities.insert(KeywordAbility::FirstStrike); } // Handle Protection keyword with value if let Some(protection_text) = rules_text.find("Protection ") { abilities.insert(KeywordAbility::Protection); // Extract the protection value (e.g., "from black") // and store it in ability_values } Self { abilities, ability_values } } } }
Stack and Effects
#![allow(unused)] fn main() { /// An item on the stack #[derive(Debug, Clone, Serialize, Deserialize, Reflect)] pub struct StackItem { /// Unique identifier pub id: Uuid, /// Source entity of the stack item pub source: Entity, /// Controller of the stack item pub controller: Entity, /// Type of stack item pub item_type: StackItemType, /// Target entities pub targets: Vec<Entity>, /// Effects to apply when resolved pub effects: Vec<Effect>, } /// Types of stack items #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Reflect)] pub enum StackItemType { /// A spell being cast Spell, /// An activated ability ActivatedAbility, /// A triggered ability TriggeredAbility, } /// An effect that can be applied to the game state #[derive(Debug, Clone, Serialize, Deserialize, Reflect)] pub enum Effect { /// Deal damage to target(s) DealDamage { /// Amount of damage amount: u32, /// Source of the damage source: Entity, }, /// Draw cards DrawCards { /// Number of cards to draw count: u32, /// Player who draws the cards player: Entity, }, /// Add mana to a player's mana pool AddMana { /// Mana to add mana: Mana, /// Player to receive the mana player: Entity, }, /// Destroy target permanent(s) DestroyPermanent { /// Whether the permanent can be regenerated can_regenerate: bool, }, /// Exile target(s) Exile { /// Return zone if temporary return_zone: Option<ZoneType>, /// When to return (e.g., end of turn) return_condition: Option<ReturnCondition>, }, // And many more effect types... } }
Game Actions
#![allow(unused)] fn main() { /// Actions that players can take #[derive(Debug, Clone, Serialize, Deserialize, Reflect)] pub enum PlayerAction { /// Play a land PlayLand { /// The land card to play card: Entity, }, /// Cast a spell CastSpell { /// The spell to cast card: Entity, /// Targets for the spell targets: Vec<Entity>, /// Mana to spend mana: Mana, }, /// Activate an ability ActivateAbility { /// Source of the ability source: Entity, /// Index of the ability on the source ability_index: usize, /// Targets for the ability targets: Vec<Entity>, /// Mana to spend mana: Option<Mana>, }, /// Attack with creatures DeclareAttackers { /// Attacking creatures attackers: Vec<(Entity, Entity)>, // (Attacker, Defender) }, /// Block with creatures DeclareBlockers { /// Blocking assignments blockers: Vec<(Entity, Entity)>, // (Blocker, Attacker) }, /// Pass priority PassPriority, /// Mulligan Mulligan, /// Keep hand KeepHand, /// Concede game Concede, } }
Network Types
#![allow(unused)] fn main() { /// Network action to synchronize across clients #[derive(Debug, Clone, Serialize, Deserialize, Reflect)] pub struct NetworkAction { /// Player who initiated the action pub player_id: u64, /// The action taken pub action: PlayerAction, /// Timestamp for ordering pub timestamp: f64, /// Sequence number for this client pub sequence: u32, } /// Rollback information for network synchronization #[derive(Debug, Clone, Serialize, Deserialize, Reflect)] pub struct RollbackInfo { /// Snapshot ID to roll back to pub snapshot_id: Uuid, /// Reason for rollback pub reason: RollbackReason, /// New actions to apply after rollback pub actions: Vec<NetworkAction>, } /// Reasons for a rollback #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Reflect)] pub enum RollbackReason { /// State desynchronization detected StateMismatch, /// Random number generator desynchronization RngMismatch, /// Network latency compensation LatencyCompensation, /// Action validation failure InvalidAction, } }
Event Types
#![allow(unused)] fn main() { /// Event triggered when a phase changes #[derive(Event, Debug, Clone)] pub struct PhaseChangeEvent { /// The new phase pub new_phase: Phase, /// The new step (if applicable) pub new_step: Option<Step>, /// The previous phase pub old_phase: Phase, /// The previous step (if applicable) pub old_step: Option<Step>, } /// Event triggered when damage is dealt #[derive(Event, Debug, Clone)] pub struct DamageEvent { /// Entity receiving damage pub target: Entity, /// Entity dealing damage pub source: Entity, /// Amount of damage pub amount: u32, /// Whether the damage is combat damage pub is_combat_damage: bool, } /// Event triggered when a card changes zones #[derive(Event, Debug, Clone)] pub struct ZoneChangeEvent { /// The card that changed zones pub card: Entity, /// The source zone pub from: ZoneType, /// The destination zone pub to: ZoneType, /// The entity that owns the destination zone pub to_owner: Entity, /// Position in the destination zone (if applicable) pub position: Option<usize>, } /// Event triggered when a snapshot should be taken #[derive(Event, Debug, Clone)] pub enum SnapshotEvent { /// Take a new snapshot Take, /// Apply a specific snapshot Apply(Uuid), /// Save the current state to disk Save(String), /// Load a state from disk Load(String), } }
Integration Types
#![allow(unused)] fn main() { /// Configuration for the game engine #[derive(Resource, Debug, Clone, Serialize, Deserialize)] pub struct GameConfig { /// Number of players pub player_count: usize, /// Starting life total pub starting_life: u32, /// Whether commander damage is enabled pub enable_commander_damage: bool, /// Starting hand size pub starting_hand_size: usize, /// Maximum hand size pub maximum_hand_size: usize, /// Number of cards to draw per turn pub draw_per_turn: usize, /// Random seed for deterministic gameplay pub random_seed: Option<u64>, } /// Configuration for snapshots #[derive(Resource, Debug, Clone)] pub struct SnapshotConfig { /// Whether to automatically take snapshots on turn change pub auto_snapshot_on_turn: bool, /// Whether to automatically take snapshots on phase change pub auto_snapshot_on_phase: bool, /// Maximum number of snapshots to process per frame pub max_snapshots_per_frame: usize, /// Compression level for snapshots (0-9) pub compression_level: u8, } }
Component Reference
This document provides a comprehensive reference of the various components used in Rummage to represent game entities and their properties.
Overview
Components in Bevy ECS are small, reusable pieces of data that are attached to entities. Rummage uses components to represent various aspects of Magic: The Gathering cards, players, and game elements.
Card Components
Components that represent aspects of Magic: The Gathering cards.
Core Card Components
#![allow(unused)] fn main() { /// The name of a card #[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Component)] pub struct CardName { pub name: String, } /// The mana cost of a card #[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Component)] pub struct CardCost { pub cost: Mana, } /// The type information of a card (creature, instant, etc.) #[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Component)] pub struct CardTypeInfo { pub types: CardTypes, } /// The card's rules text #[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Component)] pub struct CardRulesText { pub rules_text: String, } /// The specific details of a card (power/toughness for creatures, etc.) #[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Component)] pub struct CardDetailsComponent { pub details: CardDetails, } /// The keyword abilities of a card #[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Component)] pub struct CardKeywords { pub keywords: KeywordAbilities, } }
Card State Components
#![allow(unused)] fn main() { /// Indicates a tapped card #[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Component)] pub struct Tapped; /// Indicates a card with summoning sickness #[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Component)] pub struct SummoningSickness; /// Indicates a card that is attacking #[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Component)] pub struct Attacker { pub attacking_player: Entity, } /// Indicates a card that is blocking #[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Component)] pub struct Blocker { pub blocking: Entity, } /// Damage marked on a card #[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Component)] pub struct DamageMarked { pub amount: u32, } /// Counters on a card #[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Component)] pub struct Counters { pub counter_map: HashMap<CounterType, u32>, } /// Indicates a card is a token #[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Component)] pub struct Token; }
Zone Components
#![allow(unused)] fn main() { /// Indicates which game zone a card is in #[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Component)] pub struct Zone { pub zone_type: ZoneType, pub owner: Entity, pub position: Option<usize>, } /// Represents a game zone (library, graveyard, etc.) #[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Component)] pub struct ZoneContainer { pub zone_type: ZoneType, pub owner: Entity, pub contents: Vec<Entity>, } }
Player Components
Components that represent aspects of players.
Core Player Components
#![allow(unused)] fn main() { /// Core player component #[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Component)] pub struct Player { pub id: usize, pub life_total: i32, pub is_active: bool, } /// Player's mana pool #[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Component)] pub struct ManaPool { pub mana: Mana, } /// Player's hand size #[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Component)] pub struct HandSize { pub current: usize, pub maximum: usize, } }
Commander-Specific Player Components
#![allow(unused)] fn main() { /// Commander damage received by a player #[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Component)] pub struct CommanderDamage { pub damage_map: HashMap<Entity, u32>, } /// Commander color identity restrictions #[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Component)] pub struct ColorIdentity { pub colors: HashSet<Color>, } }
Game State Components
Components that represent aspects of the game state.
Turn and Phase Components
#![allow(unused)] fn main() { /// Current game turn #[derive(Resource, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Resource)] pub struct GameTurn { pub number: u32, pub active_player: Entity, } /// Current game phase #[derive(Resource, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Resource)] pub struct GamePhase { pub phase: Phase, pub step: Option<Step>, } /// Priority holder #[derive(Resource, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Resource)] pub struct Priority { pub player: Entity, pub passed_players: HashSet<Entity>, } }
Stack Components
#![allow(unused)] fn main() { /// The game stack (for spells and abilities) #[derive(Resource, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Resource)] pub struct Stack { pub items: Vec<StackItem>, } /// An item on the stack #[derive(Debug, Clone, Reflect, Serialize, Deserialize)] pub struct StackItem { pub id: Uuid, pub source: Entity, pub controller: Entity, pub item_type: StackItemType, pub targets: Vec<Entity>, pub effects: Vec<Effect>, } }
UI Components
Components used for the user interface representation.
Card Visualization
#![allow(unused)] fn main() { /// Visual representation of a card #[derive(Component, Debug, Clone)] pub struct CardVisual { pub entity: Entity, pub card_face: Handle<Image>, pub is_facedown: bool, pub visual_state: CardVisualState, } /// UI states for cards #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum CardVisualState { Normal, Selected, Targeted, Highlighted, Disabled, } /// Interactive card component #[derive(Component, Debug, Clone)] pub struct Interactable { pub enabled: bool, pub interaction_type: InteractionType, } }
Layout Components
#![allow(unused)] fn main() { /// Battlefield position #[derive(Component, Debug, Clone)] pub struct BattlefieldPosition { pub row: usize, pub column: usize, pub rotation: f32, } /// Hand position #[derive(Component, Debug, Clone)] pub struct HandPosition { pub index: usize, pub total: usize, } }
Network Components
Components used for network synchronization.
#![allow(unused)] fn main() { /// Component for entities that should be synchronized over the network #[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Component)] pub struct NetworkSynchronized { pub id: Uuid, pub version: u32, pub owner: Option<u64>, // Network ID of the owning client } /// Action performed by a player that needs network broadcasting #[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Component)] pub struct PlayerAction { pub player: Entity, pub action_type: ActionType, pub targets: Vec<Entity>, pub timestamp: f64, } }
System Components
Internal components used by the game engine.
#![allow(unused)] fn main() { /// Marker for entities that should be included in snapshots #[derive(Component, Debug, Clone)] pub struct Snapshotable; /// Temporary component for marking entities that need processing #[derive(Component, Debug, Clone)] pub struct NeedsProcessing; /// Component for tracking when an entity was created #[derive(Component, Debug, Clone, Reflect, Serialize, Deserialize)] #[reflect(Component)] pub struct Created { pub timestamp: f64, pub turn: u32, } }
Component Integration
Components are used together to represent complex game objects:
#![allow(unused)] fn main() { // Example of a full creature card entity with its components commands.spawn(( // Core card information Card::new( "Grizzly Bears", Mana::new(1, 0, 0, 0, 1, 0), // 1G CardTypes::new_creature(vec!["Bear".to_string()]), CardDetails::new_creature(2, 2), "", // No rules text ), // State information Zone { zone_type: ZoneType::Battlefield, owner: player_entity, position: None, }, // Visual representation CardVisual { entity: Entity::PLACEHOLDER, card_face: card_image_handle, is_facedown: false, visual_state: CardVisualState::Normal, }, // Battlefield positioning BattlefieldPosition { row: 0, column: 0, rotation: 0.0, }, // System markers Snapshotable, NetworkSynchronized { id: Uuid::new_v4(), version: 0, owner: Some(player_network_id), }, )); }
Component Registration
Components must be registered with the Bevy type registry to be used with reflection, serialization, and UI:
#![allow(unused)] fn main() { fn register_components(app: &mut App) { app.register_type::<CardName>() .register_type::<CardCost>() .register_type::<CardTypeInfo>() .register_type::<CardRulesText>() .register_type::<CardDetailsComponent>() .register_type::<CardKeywords>() .register_type::<Tapped>() .register_type::<SummoningSickness>() .register_type::<Attacker>() .register_type::<Blocker>() .register_type::<DamageMarked>() .register_type::<Counters>() .register_type::<Token>() .register_type::<Zone>() .register_type::<ZoneContainer>() .register_type::<Player>() .register_type::<ManaPool>() .register_type::<HandSize>() .register_type::<CommanderDamage>() .register_type::<ColorIdentity>() .register_type::<NetworkSynchronized>() .register_type::<PlayerAction>() .register_type::<Created>(); } }
System Reference
This document provides a detailed reference of the various systems in Rummage that implement game logic, handle events, and manage game state.
Overview
Systems in Bevy ECS are the workhorses that process game logic. In Rummage, systems are organized into several major categories that map to Magic: The Gathering game concepts.
State Management Systems
State management systems handle the tracking and updating of game state.
Game State Systems
#![allow(unused)] fn main() { // Initialize the primary game state fn init_game_state(mut commands: Commands) { commands.insert_resource(GameState { turn: 1, phase: Phase::Beginning, step: Step::Untap, active_player: 0, priority_player: 0, // ... }); } // Update the game state based on input events fn update_game_state( mut game_state: ResMut<GameState>, mut phase_events: EventReader<PhaseChangeEvent>, // ... ) { // Implementation... } }
Player State Systems
#![allow(unused)] fn main() { // Initialize player state fn init_player_state(mut commands: Commands, game_config: Res<GameConfig>) { for player_idx in 0..game_config.player_count { commands.spawn(( Player { id: player_idx, life_total: 40, // Commander starting life // ... }, // Other components... )); } } // Update player state based on game events fn update_player_state( mut player_query: Query<(&mut Player, &CommanderDamage)>, mut damage_events: EventReader<PlayerDamageEvent>, // ... ) { // Implementation... } }
Card State Systems
#![allow(unused)] fn main() { // Track state of cards on the battlefield fn update_battlefield_cards( mut commands: Commands, mut card_query: Query<(Entity, &mut Card, &Zone)>, mut zone_change_events: EventReader<ZoneChangeEvent>, // ... ) { // Implementation... } }
Stack Systems
Systems that implement the MTG stack and priority mechanisms.
Stack Management
#![allow(unused)] fn main() { // Add objects to the stack fn add_to_stack( mut commands: Commands, mut stack: ResMut<Stack>, mut stack_events: EventReader<AddToStackEvent>, // ... ) { // Implementation... } // Resolve objects from the stack fn resolve_stack( mut commands: Commands, mut stack: ResMut<Stack>, mut game_state: ResMut<GameState>, // ... ) { // Implementation... } }
Priority Systems
#![allow(unused)] fn main() { // Handle passing of priority between players fn handle_priority( mut game_state: ResMut<GameState>, mut priority_events: EventReader<PriorityPassedEvent>, // ... ) { // Implementation... } }
Combat Systems
Systems that implement the combat phase and damage resolution.
Combat Flow
#![allow(unused)] fn main() { // Start the combat phase fn start_combat( mut commands: Commands, mut game_state: ResMut<GameState>, mut phase_events: EventWriter<PhaseChangeEvent>, // ... ) { // Implementation... } // Handle the declare attackers step fn declare_attackers( mut commands: Commands, mut game_state: ResMut<GameState>, mut attacker_query: Query<(Entity, &Card), With<Attacker>>, // ... ) { // Implementation... } // Handle the declare blockers step fn declare_blockers( mut commands: Commands, mut game_state: ResMut<GameState>, mut blocker_query: Query<(Entity, &Card), With<Blocker>>, // ... ) { // Implementation... } // Combat damage calculation and assignment fn combat_damage( mut commands: Commands, mut attacker_query: Query<(Entity, &Attacker, &Card)>, mut blocker_query: Query<(Entity, &Blocker, &Card)>, mut damage_events: EventWriter<DamageEvent>, // ... ) { // Implementation... } }
Combat Damage
#![allow(unused)] fn main() { // Apply combat damage to creatures and players fn apply_combat_damage( mut commands: Commands, mut creature_query: Query<(Entity, &mut Card)>, mut player_query: Query<(Entity, &mut Player)>, mut damage_events: EventReader<DamageEvent>, // ... ) { // Implementation... } }
Turn Systems
Systems that handle turn structure and phase progression.
Turn Progression
#![allow(unused)] fn main() { // Start a new turn fn start_turn( mut commands: Commands, mut game_state: ResMut<GameState>, mut turn_events: EventWriter<TurnStartEvent>, // ... ) { // Implementation... } // Transition between phases fn change_phase( mut game_state: ResMut<GameState>, mut phase_events: EventReader<PhaseChangeEvent>, // ... ) { // Implementation... } // End the current turn fn end_turn( mut commands: Commands, mut game_state: ResMut<GameState>, mut turn_events: EventWriter<TurnEndEvent>, // ... ) { // Implementation... } }
Commander Systems
Systems specific to the Commander format.
Commander Tax
#![allow(unused)] fn main() { // Track and apply commander tax fn apply_commander_tax( mut commands: Commands, mut commander_query: Query<(Entity, &mut CommanderCard)>, mut cast_events: EventReader<CommanderCastEvent>, // ... ) { // Implementation... } }
Commander Damage
#![allow(unused)] fn main() { // Track commander damage between players fn track_commander_damage( mut commands: Commands, mut player_query: Query<(Entity, &mut CommanderDamage)>, mut damage_events: EventReader<CommanderDamageEvent>, // ... ) { // Implementation... } }
Event Systems
Systems that process various game events.
Event Dispatch
#![allow(unused)] fn main() { // Main event dispatcher system fn dispatch_events( mut commands: Commands, mut card_played_events: EventReader<CardPlayedEvent>, mut zone_change_events: EventReader<ZoneChangeEvent>, // Other event readers... mut card_query: Query<(Entity, &mut Card)>, // ... ) { // Implementation... } }
Event Processing
#![allow(unused)] fn main() { // Process various types of events fn process_card_played( mut commands: Commands, mut card_played_events: EventReader<CardPlayedEvent>, // ... ) { // Implementation... } fn process_zone_changes( mut commands: Commands, mut zone_change_events: EventReader<ZoneChangeEvent>, // ... ) { // Implementation... } }
Snapshot Systems
Systems for capturing and restoring game state.
Snapshot Creation
#![allow(unused)] fn main() { // Create a game state snapshot fn create_snapshot( world: &World, game_state: Res<GameState>, mut snapshot_events: EventWriter<SnapshotEvent>, // ... ) { // Implementation... } }
Snapshot Restoration
#![allow(unused)] fn main() { // Restore from a snapshot fn apply_snapshot( mut commands: Commands, snapshot: Res<GameSnapshot>, // ... ) { // Implementation... } }
UI Integration Systems
Systems that connect game logic to the user interface.
UI Update Systems
#![allow(unused)] fn main() { // Update UI elements based on game state fn update_battlefield_ui( mut commands: Commands, card_query: Query<(Entity, &Card, &Zone)>, mut ui_query: Query<(Entity, &mut UiTransform, &CardUi)>, // ... ) { // Implementation... } // Handle user input events fn handle_card_interaction( mut commands: Commands, mut interaction_events: EventReader<CardInteractionEvent>, card_query: Query<(Entity, &Card)>, // ... ) { // Implementation... } }
Network Integration Systems
Systems that handle network synchronization.
State Synchronization
#![allow(unused)] fn main() { // Synchronize game state across the network fn sync_game_state( mut commands: Commands, game_state: Res<GameState>, mut replicon: ResMut<Replicon>, // ... ) { // Implementation... } }
Action Broadcasting
#![allow(unused)] fn main() { // Broadcast player actions to all clients fn broadcast_actions( mut commands: Commands, mut action_events: EventReader<PlayerActionEvent>, mut replicon: ResMut<Replicon>, // ... ) { // Implementation... } }
System Registration
Systems are registered with the Bevy App in the following manner:
#![allow(unused)] fn main() { // Register all game systems fn build_game_systems(app: &mut App) { app // Core game state systems .add_systems(Startup, init_game_state) .add_systems(Update, update_game_state) // Turn and phase systems .add_systems(Update, ( start_turn, change_phase, end_turn, ).chain()) // Combat systems .add_systems(Update, ( start_combat, declare_attackers, declare_blockers, combat_damage, apply_combat_damage, ).chain().run_if(in_combat_phase)) // Stack systems .add_systems(Update, ( add_to_stack, resolve_stack, handle_priority, ).chain()) // Event systems .add_systems(Update, dispatch_events) .add_systems(Update, ( process_card_played, process_zone_changes, )) // Commander-specific systems .add_systems(Update, ( apply_commander_tax, track_commander_damage, )) // Snapshot systems .add_systems(Update, ( create_snapshot.run_if(resource_exists::<SnapshotConfig>()), apply_snapshot.run_if(resource_exists::<PendingSnapshots>()), )) // UI integration systems .add_systems(Update, ( update_battlefield_ui, handle_card_interaction, )) // Network integration systems .add_systems(Update, ( sync_game_state, broadcast_actions, ).run_if(resource_exists::<Replicon>())); } }
Magic: The Gathering Rules Reference
This section provides a comprehensive reference to the Magic: The Gathering rules as implemented in Rummage. It serves as a bridge between the official comprehensive rules and our game engine implementation.
How Rules Are Organized
The Rummage documentation organizes Magic: The Gathering rules into three layers:
- MTG Rules Reference (this section) - A high-level explanation of the rules and mechanics with links to implementation details
- MTG Core Rules - Detailed implementation of fundamental rules shared by all formats
- Format-Specific Rules - Extensions and modifications for specific formats (e.g., Commander)
This layered approach ensures that common rules are documented once in the core layer, while format-specific variations are documented in their respective format sections.
Core Rules vs. Format Rules
Understanding the distinction between core rules and format rules is essential:
- Core Rules: Universal mechanics that apply to all Magic: The Gathering games (turn structure, stack, zones, etc.)
- Format Rules: Additional rules and modifications specific to a format (Commander damage, partner commanders, etc.)
In Rummage, both are implemented as composable ECS systems, allowing shared core systems with format-specific extensions.
Implementation Methodology
Our rules implementation follows a methodology designed for correctness, testability, and extensibility:
- Rule Extraction: Rules are extracted from the Comprehensive Rules
- System Design: Rules are modeled as composable Bevy ECS systems
- State Representation: Game state is represented as entities with components
- Event-Driven Logic: Rules are triggered by and produce game events
- Deterministic Execution: Rules execute deterministically for network play
Integration with Core Systems
The rules implementation integrates with several core systems:
Snapshot System
The Snapshot System works closely with the rules implementation to:
- Capture game state at specific points in the turn structure
- Ensure deterministic rule application for networked games
- Enable replay and analysis of rule applications
- Support testing of complex rule interactions
For more information on how snapshots are used in testing rule implementations, see Snapshot Testing.
Rules Categories
The MTG rules are broken down into the following main categories:
Game Structure Rules
- Turn Structure (Implementation) - Phases, steps, and the progression of a turn
- Stack (Implementation) - How spells and abilities are put onto and resolved from the stack
- Zones (Implementation) - Game areas where cards can exist (library, hand, battlefield, etc.)
- State-Based Actions (Implementation) - Automatic game checks that maintain game integrity
Card Rules
- Card Types - The various types of cards and their characteristics
- Card States - Different states a card can be in (tapped, face-down, etc.)
- Mana Costs - How mana costs work and are calculated
Gameplay Rules
- Combat (Implementation) - Rules for attacking, blocking, and combat damage
- Targeting - How targets are selected and validated
- Effects - Different types of effects and how they're applied
- Keywords - Standard keyword abilities and their implementations
Advanced Rules
- Triggered Abilities - How triggered abilities work and are resolved
- Replacement Effects - How replacement effects modify events
- Priority - The system determining when players can take actions
- Layer System - How continuous effects are applied in a specific order
Format-Specific Rules
This reference provides high-level explanations of format-specific rules. For detailed implementation details, refer to the format-specific documentation:
- Commander-Specific Rules - Reference for Commander format rules
- Commander Format Implementation - Detailed implementation
Note: Currently, only the Commander format is fully documented. Additional formats like Two-Headed Giant and Planechase may be added in the future.
Implementation Examples
Throughout the rules documentation, you'll find code examples showing how the rules are implemented in Rummage:
#![allow(unused)] fn main() { // Example: A system implementing state-based actions pub fn check_state_based_actions( mut commands: Commands, mut creatures: Query<(Entity, &Creature, &Health)>, mut players: Query<(Entity, &Player)>, mut game_events: EventWriter<GameEvent>, ) { // Check for creatures with lethal damage for (entity, creature, health) in creatures.iter() { if health.damage >= creature.toughness { // Creature has lethal damage, destroy it commands.entity(entity).insert(Destroyed); game_events.send(GameEvent::CreatureDestroyed { entity }); } } // Check for players with zero or less life for (entity, player) in players.iter() { if player.life <= 0 { game_events.send(GameEvent::PlayerLost { player: entity, reason: LossReason::ZeroLife, }); } } // Other state-based actions... } }
Rules Interactions
Magic: The Gathering is known for its complex rule interactions. The documentation explains how different rule systems interact:
- How the stack interacts with state-based actions
- How replacement effects modify zone changes
- How continuous effects are applied in layers
- How priority flows during complex game scenarios
Testing Rules Correctness
The Rummage engine extensively tests rules correctness:
- Unit tests for individual rule applications
- Integration tests for interactions between rule systems
- Scenario tests for complex game states
- Regression tests for previously identified issues
For more details on how rules are tested, see the Testing Overview.
Rules Implementation Resources
- Comprehensive Rules PDF - The official comprehensive rules document
- MTG Wiki - Community-maintained rules explanations
- Scryfall - Card database with official rulings
- MTG Salvation - Community discussion of rules interactions
How to Use This Documentation
- For a high-level overview of a rule, start with the relevant page in this MTG Rules section
- For implementation details, follow the links to the MTG Core Rules section
- For format-specific rules, check the format's dedicated rules documentation
- For code examples, look at the implementation snippets provided throughout
Next: Comprehensive Rules Overview
Magic: The Gathering Comprehensive Rules
This page provides an overview of the Magic: The Gathering Comprehensive Rules and how they are implemented in the Rummage game engine.
Introduction
The Magic: The Gathering Comprehensive Rules are the complete ruleset for the game, covering all possible interactions and edge cases. The current version used in Rummage is dated February 7, 2025, and can be found in MagicCompRules 20250207.txt.
Implementation Approach
Rummage implements the Comprehensive Rules through a modular system of components and systems. Each section of the rules is mapped to specific game logic:
-
Game Concepts (Rules 100-199)
- Core game logic, turn structure, and win conditions
- Starting the game, ending the game
- Colors, mana, and basic card types
-
Parts of a Card (Rules 200-299)
- Card data structures
- Card characteristics and attributes
- Card types and subtypes
-
Card Types (Rules 300-399)
- Type-specific behaviors (creatures, artifacts, etc.)
- Type-changing effects
- Supertype rules (legendary, basic, etc.)
-
Zones (Rules 400-499)
- Zone implementation
- Movement between zones
- Zone-specific rules
-
Turn Structure (Rules 500-599)
- Phase and step management
- Beginning, combat, and ending phases
- Extra turns and additional phases
-
Spells, Abilities, and Effects (Rules 600-699)
- Spell casting
- Ability implementation
- Effect resolution
-
Additional Rules (Rules 700-799)
- Actions and special actions
- State-based actions
- Commander-specific rules
Testing Approach
Each section of the Comprehensive Rules is covered by specific test cases to ensure compliance:
- Rule-Specific Tests: Each rule with significant game impact has dedicated unit tests
- Interaction Tests: Tests for complex interactions between different rules
- Edge-Case Tests: Tests for unusual rule applications and corner cases
- Oracle Rulings: Tests based on official Wizards of the Coast rulings
Key Implementation Challenges
- Rule Interdependencies: Many rules reference or depend on other rules, requiring careful implementation order
- State-Based Actions: Continuous checking of game state conditions (rule 704)
- Layering System: Implementation of continuous effects in the correct order (rule 613)
- Replacement Effects: Handling multiple replacement effects that could apply to the same event (rule 616)
Implementation Status
The table below summarizes the implementation status of major rule sections:
Rule Section | Description | Status | Notes |
---|---|---|---|
100-199 | Game Concepts | ✅ | Core game flow implemented |
200-299 | Parts of a Card | ✅ | Card model complete |
300-309 | Card Types | ✅ | All card types supported |
400-499 | Zones | ✅ | All zones implemented |
500-599 | Turn Structure | ✅ | Complete turn sequence |
600-609 | Spells | ✅ | Spell casting fully supported |
610-613 | Effects | 🔄 | Complex continuous effects in progress |
614-616 | Replacement Effects | 🔄 | Being implemented |
700-799 | Additional Rules | 🔄 | Specialized rules in development |
Legend:
- ✅ Implemented and tested
- 🔄 In progress
- ⚠️ Planned but not yet implemented
Example Rule Implementation
Here's a simplified example of how a rule is implemented in the Rummage codebase:
#![allow(unused)] fn main() { // Implementing Rule 302.6: "A creature's activated ability with the tap symbol // in its activation cost can't be activated unless the creature has been under // its controller's control continuously since their most recent turn began." pub fn can_use_tap_ability( creature: &Creature, game_state: &GameState ) -> bool { // Check if creature has summoning sickness if !creature.has_haste && creature.turns_under_current_control < 1 { return false; } // More checks as needed... true } }
References
Commander-Specific Rules
This page provides a high-level reference for the official Commander format rules. For detailed implementation specifics, see the Commander Format documentation.
What Is Commander?
Commander (formerly known as Elder Dragon Highlander or EDH) is a multiplayer format for Magic: The Gathering created by players and embraced by Wizards of the Coast as an official format. It emphasizes social gameplay and creative deck building around a chosen legendary creature.
Official Commander Rules
The Commander format is governed by a specific set of rules maintained by the Commander Rules Committee:
Deck Construction Rules
- Players choose a legendary creature as their "commander"
- A deck contains exactly 100 cards, including the commander
- Except for basic lands, no two cards in the deck may have the same English name
- A card's color identity must be within the color identity of the commander
- Color identity includes colored mana symbols in costs and rules text
Game Play Rules
- Players begin with 40 life
- Commanders begin the game in the "command zone"
- While a commander is in the command zone, it may be cast, subject to normal timing restrictions
- Each time a player casts their commander from the command zone, it costs an additional {2} for each previous time they've cast it
- If a commander would be exiled or put into a hand, graveyard, or library, its owner may choose to move it to the command zone instead
- A player that has been dealt 21 or more combat damage by the same commander loses the game
Rule Implementation References
For detailed implementation of these rules in Rummage, refer to these sections:
- Color Identity - Implementation of color identity rules
- Command Zone - How the command zone functions
- Commander Tax - Implementation of additional commander casting costs
- Commander Damage - Tracking and applying commander combat damage
- Zone Transitions - Commander movement between zones
Commander Variants
Rummage supports these common Commander variants:
Partner Commanders
Some legendary creatures have the "Partner" ability, allowing a deck to have two commanders. See Partner Commanders for implementation details.
Commander Ninjutsu
A special ability that allows commanders to be put onto the battlefield from the command zone. See Commander Ninjutsu for implementation details.
Brawl
A variant with 60-card decks, only using Standard-legal cards and starting at 25 life.
Commander Death Triggers
Special rules for how commander death and zone changes work. See Commander Death Triggers for implementation details.
Commander-Specific Cards
Many cards have been designed specifically for the Commander format. See Commander-Specific Cards for details on how these cards are implemented.
References
Turn Structure
This document provides an overview of the turn structure rules in Magic: The Gathering. For the detailed implementation in Rummage, please see Turn Structure Implementation.
Overview of Turn Structure
A turn in Magic: The Gathering consists of five phases, some of which are divided into steps. The phases proceed in the following order:
-
Beginning Phase
- Untap Step
- Upkeep Step
- Draw Step
-
First Main Phase
-
Combat Phase
- Beginning of Combat Step
- Declare Attackers Step
- Declare Blockers Step
- Combat Damage Step
- End of Combat Step
-
Second Main Phase
-
Ending Phase
- End Step
- Cleanup Step
Phase and Step Rules
Phase transitions follow these rules:
- Each phase or step begins with game events that happen automatically
- Then, the active player receives priority
- When all players pass priority in succession with an empty stack, the phase or step ends
- The game proceeds to the next phase or step
Special Turn Structure Rules
Priority
- Players receive priority in APNAP (Active Player, Non-Active Player) order
- No player receives priority during the untap step or cleanup step (unless a trigger occurs)
- The active player receives priority first in each phase/step
Skipping Steps
- If no player has a triggered ability triggering at the beginning of a step and no player takes an action during that step, that step is skipped
- The upkeep step, draw step, and end step are never skipped this way
Combat Phase
The combat phase has special rules:
- Only creatures can attack
- Creatures with summoning sickness cannot attack
- The active player chooses which creatures attack and which player or planeswalker they attack
- The defending player(s) choose which creatures block and how blocking is assigned
Format-Specific Variations
Different formats may have specific variations in how turns proceed:
- Commander: See Commander Turn Structure for multiplayer turn order rules
- Two-Headed Giant: Teams share turns and certain steps
- Planechase: Additional actions may be taken during main phases
Related Documentation
For detailed information about turn structure in Rummage, please see:
- Turn Structure Implementation: Core implementation details
- Combat: How combat fits into the turn structure
- Stack: How spells and abilities interact with turn structure
- Priority System: Detailed rules on priority
For format-specific implementations:
- Commander Turn Structure: Commander-specific turn rules
- Multiplayer Turns: How turns work in multiplayer games
The Stack
This document provides an overview of the stack rules in Magic: The Gathering. For the detailed implementation in Rummage, please see Stack and Priority System.
What is the Stack?
The stack is a zone in Magic: The Gathering where spells and abilities exist while waiting to resolve. It uses the "last in, first out" (LIFO) principle - the most recently added object on the stack is the first to resolve.
Stack Rules
Adding to the Stack
The following objects use the stack:
- Spells: When a player casts a spell, it's put on the stack.
- Activated Abilities: When a player activates an ability, it's put on the stack.
- Triggered Abilities: When a triggered ability triggers, it's put on the stack the next time a player would receive priority.
Resolution Process
- Each player, in turn order starting with the active player, receives priority.
- A player with priority may:
- Cast a spell
- Activate an ability
- Pass priority
- When all players pass priority in succession:
- If the stack is empty, the current phase or step ends.
- If the stack has objects, the topmost object resolves.
Resolving Stack Objects
When a spell or ability resolves:
- Its instructions are followed in order.
- If it's a permanent spell, it enters the battlefield.
- It leaves the stack and ceases to exist (unless it's a permanent spell).
Special Stack Rules
- Split second: Spells with split second prevent players from casting spells or activating abilities while they're on the stack (except for mana abilities and certain special actions).
- Counterspells: These spells target other spells on the stack and prevent them from resolving.
- Mana abilities: These abilities don't use the stack and resolve immediately.
- Special actions: Certain game actions don't use the stack (e.g., playing a land).
Related Documentation
For the detailed implementation of the stack in Rummage, including code examples and integration with other systems, see:
- Stack and Priority System: Core implementation details
- Priority System: Detailed rules on priority
- Spell Casting: Rules for casting spells
- Abilities: Types of abilities and how they work
Game Zones
This document provides an overview of game zones in Magic: The Gathering. For the detailed implementation in Rummage, please see Zones Implementation.
Overview
Magic: The Gathering divides the game into distinct areas called zones. Each zone has specific rules governing how cards can enter, leave, and interact while in that zone.
Standard Game Zones
The standard game includes the following zones:
Library
- The player's deck of cards
- Cards are face-down and in a random order
- Players draw from the top of their library
- Running out of cards in the library can cause a player to lose the game
Hand
- Cards held by a player
- Normally hidden from opponents
- Maximum hand size (usually seven) is enforced during the cleanup step
- Cards in hand can be cast or played according to their type and timing restrictions
Battlefield
- Permanents (lands, creatures, artifacts, enchantments, planeswalkers) exist on the battlefield
- Cards on the battlefield are typically face-up (exceptions exist for morphed/manifested cards)
- Cards on the battlefield can tap, untap, attack, block, and be affected by other cards
- Positioning on the battlefield can matter for certain cards and effects
Graveyard
- Discard pile for destroyed, sacrificed, or discarded cards
- Cards are face-up and can be examined by any player
- Order of cards in the graveyard matters for some effects
- Some abilities allow cards to be played or returned from the graveyard
Stack
- Holds spells and abilities that have been cast or activated but haven't resolved yet
- Operates as a last-in, first-out (LIFO) data structure
- Spells and abilities resolve one at a time from top to bottom
- Players can respond to objects on the stack by adding more spells or abilities
Exile
- Cards removed from the game
- Cards are typically face-up unless specified otherwise
- Generally, cards in exile cannot interact with the game
- Some cards can return cards from exile or interact with exiled cards
Command
- Special zone for format-specific cards or game elements
- In Commander, this zone holds commander cards
- In other formats, it may hold emblems, planes, schemes, etc.
- Cards in the command zone have special rules for how they can be cast or used
Zone Transitions
Cards can move between zones according to specific rules:
- Casting a Spell: Card moves from hand to the stack
- Spell Resolution: Card moves from stack to battlefield (permanents) or graveyard (instants/sorceries)
- Destroying/Sacrificing: Permanent moves from battlefield to graveyard
- Drawing: Card moves from library to hand
- Discarding: Card moves from hand to graveyard
- Exiling: Card moves from any zone to exile
Zone Change Rules
When a card changes zones:
- It becomes a new object with no memory of its previous existence
- Counters, attachments, and continuous effects that previously affected it no longer apply
- Exceptions exist for abilities that specifically track the object across zone changes
Format-Specific Zone Rules
Different formats may modify zone rules:
- Commander: The command zone holds commander cards, which can be cast from there
- Planechase: The planar deck and active plane exist in the command zone
- Archenemy: Scheme cards reside in the command zone
Related Documentation
For more information about zones in Rummage, see:
- Zones Implementation: Detailed implementation in the core engine
- Commander Command Zone: Commander-specific implementation
- Zone Transitions: How cards move between zones
- Stack Implementation: Detailed stack zone implementation
Combat
This document provides an overview of the combat rules in Magic: The Gathering. For the detailed implementation in Rummage, please see Combat System.
Combat Phase Overview
The combat phase consists of five steps:
- Beginning of Combat Step
- Declare Attackers Step
- Declare Blockers Step
- Combat Damage Step
- End of Combat Step
Combat Flow
The flow of combat follows these steps:
Beginning of Combat Step
- The active player receives priority
- Players can cast instants and activate abilities
- No combat actions yet occur
Declare Attackers Step
- The active player declares which of their creatures are attacking and what player or planeswalker each is attacking
- Tapped creatures and creatures with summoning sickness (that don't have haste) cannot attack
- Once attackers are declared, triggered abilities trigger and the active player receives priority
Declare Blockers Step
- The defending player(s) declare which of their untapped creatures are blocking and which attacker each is blocking
- A single creature can block only one attacker unless it has the ability to block multiple attackers
- Multiple creatures can block a single attacker
- After blockers are declared, the active player assigns the combat damage order for each attacking creature that's blocked by multiple creatures
- Triggered abilities trigger and the active player receives priority
Combat Damage Step
- Combat damage is assigned and dealt simultaneously by all attacking and blocking creatures
- Creatures with first strike or double strike deal damage in a separate combat damage step before creatures without first strike
- Creatures with trample can assign excess damage to the player or planeswalker they're attacking
- After damage is dealt, triggered abilities trigger and the active player receives priority
End of Combat Step
- Final opportunity to use "until end of combat" effects
- Triggered abilities trigger and the active player receives priority
Special Combat Rules
First Strike and Double Strike
- Creatures with first strike deal combat damage before creatures without first strike
- Creatures with double strike deal combat damage twice, once during the first strike damage step and once during the regular damage step
- If any creature has first strike or double strike, there are two combat damage steps
Trample
- If all creatures blocking an attacking creature with trample are assigned lethal damage, excess damage can be assigned to the player or planeswalker that creature is attacking
Evasion Abilities
- Flying: Can only be blocked by creatures with flying or reach
- Fear: Can only be blocked by artifact creatures and/or black creatures
- Intimidate: Can only be blocked by artifact creatures and/or creatures that share a color with it
- Menace: Can't be blocked except by two or more creatures
- Shadow: Can only block or be blocked by creatures with shadow
Combat Keywords
- Vigilance: Creature doesn't tap when attacking
- Deathtouch: Any amount of damage dealt by a creature with deathtouch is considered lethal
- Lifelink: Damage dealt by a creature with lifelink causes its controller to gain that much life
- Indestructible: Creature can't be destroyed by damage or "destroy" effects
Related Documentation
For the detailed implementation of combat in Rummage, including code examples and integration with other systems, see:
- Combat System: Core implementation details
- Combat Phases: Detailed phase implementation
- First Strike and Double Strike: First/double strike implementation
- Combat Damage Calculation: How damage is calculated and applied
- Turn Structure: How combat fits into the turn
- Stack: How combat interacts with the stack
State-Based Actions
This document provides an overview of state-based actions in Magic: The Gathering. For the detailed implementation in Rummage, please see State-Based Actions.
What Are State-Based Actions?
State-based actions are game rules that are continuously checked and automatically applied whenever a player would receive priority. They handle common game situations without requiring explicit triggers.
Key State-Based Actions
The most common state-based actions include:
Creature-Related
- A creature with toughness ≤ 0 is put into its owner's graveyard
- A creature with lethal damage marked on it is destroyed
- A creature that has been dealt damage by a source with deathtouch is destroyed
- A creature with toughness greater than 0 and damage marked on it equal to or greater than its toughness has lethal damage
Player-Related
- A player with 0 or less life loses the game
- A player who attempted to draw from an empty library loses the game
- A player with 10 or more poison counters loses the game
- A player who has attempted to draw more cards than their library contains loses the game
Card-Related
- If two or more legendary permanents with the same name are on the battlefield, their owners choose one to keep and put the rest into their owners' graveyards
- If two or more planeswalkers with the same planeswalker type are on the battlefield, their controllers choose one to keep and put the rest into their owners' graveyards
- An Aura attached to an illegal object or player, or not attached to an object or player, is put into its owner's graveyard
- An Equipment or Fortification attached to an illegal permanent becomes unattached
- A token that has left the battlefield ceases to exist
- A copy of a spell in a zone other than the stack ceases to exist
When State-Based Actions Are Checked
State-based actions are checked:
- Whenever a player would receive priority
- After a spell or ability resolves
- After combat damage is dealt
- During cleanup step
They are NOT checked during the resolution of a spell or ability, or during the process of casting a spell or activating an ability.
Multiple State-Based Actions
If multiple state-based actions would apply simultaneously:
- All applicable state-based actions are performed simultaneously
- The system then checks again to see if any new state-based actions need to be performed
- This process repeats until no more state-based actions are applicable
Implementation Note
For the detailed implementation of state-based actions in Rummage, including code examples and integration with other systems, see:
- State-Based Actions Implementation: Core implementation details
- Format-Specific State-Based Actions: Commander format extensions
MTG Targeting Rules
This document outlines the rules for targeting in Magic: The Gathering as implemented in Rummage.
Core Targeting Rules
According to the Magic: The Gathering Comprehensive Rules:
- The term "target" is used to describe an object that a spell or ability will affect.
- An object that requires targets is put on the stack with those targets already chosen.
- Targets are always declared as part of casting a spell or activating an ability.
- A target must be valid both when declared and when the spell or ability resolves.
Valid Targets
A valid target must:
- Meet any specific requirements of the targeting effect
- Be in the appropriate zone (usually on the battlefield)
- Not have hexproof or shroud (relative to the controller of the targeting effect)
- Not have protection from the relevant quality of the targeting effect
Implementation
In Rummage, targeting is implemented through several components:
#![allow(unused)] fn main() { pub enum TargetType { Player, Creature, Permanent, AnyTarget, // Player or creature // ...other target types } pub struct TargetRequirement { pub target_type: TargetType, pub count: TargetCount, pub additional_requirements: Vec<Box<dyn TargetFilter>>, } }
Targeting Phases
The targeting process has several phases:
- Declaration: The player declares targets for a spell or ability
- Validation: The system validates that the targets are legal
- Resolution: When the spell or ability resolves, targets are checked again
- Effect Application: The effect is applied to valid targets
Illegal Targets
If a target becomes illegal before a spell or ability resolves:
- The spell or ability will still resolve
- The effect will not be applied to the illegal target
- If all targets are illegal, the spell or ability is countered by game rules
UI Integration
The targeting rules integrate with the UI through:
- Targeting System: UI implementation of targeting
- Drag and Drop: Using drag interactions for targeting
Examples
Single Target Spell
#![allow(unused)] fn main() { // Lightning Bolt implementation let lightning_bolt = Card::new("Lightning Bolt") .with_cost(Mana::new(0, 1, 0, 0, 0, 0)) .with_target_requirement(TargetRequirement { target_type: TargetType::AnyTarget, count: TargetCount::Exactly(1), additional_requirements: vec![], }) .with_effect(|game, targets| { for target in targets { game.deal_damage(target, 3, DamageType::Spell); } }); }
Multiple Targets Spell
#![allow(unused)] fn main() { // Electrolyze implementation let electrolyze = Card::new("Electrolyze") .with_cost(Mana::new(0, 1, 0, 1, 0, 0)) .with_target_requirement(TargetRequirement { target_type: TargetType::AnyTarget, count: TargetCount::UpTo(2), additional_requirements: vec![], }) .with_effect(|game, targets| { for target in targets { game.deal_damage(target, 1, DamageType::Spell); } game.draw_cards(game.active_player, 1); }); }
Card States in MTG
This document describes the various states that cards can have according to the Magic: The Gathering rules, and how these states are implemented in Rummage.
Core Card States
According to the Magic: The Gathering rules, cards can have the following states:
Tapped vs. Untapped
- Tapped: A card that has been turned sideways to indicate it has been used
- Untapped: A card in its normal vertical orientation
Face-up vs. Face-down
- Face-up: A card's face is visible to all players
- Face-down: A card's face is hidden from all players (with certain exceptions)
Flipped vs. Unflipped
- Flipped: A card that has been turned 180 degrees
- Unflipped: A card in its normal orientation
Special States
In addition to the core states, cards can have several special states:
Phased In/Out
- Phased In: Normal state, the card exists in the game
- Phased Out: Card is treated as though it doesn't exist
Transformed
- For double-faced cards, the state of which face is currently showing
Meld
- When certain cards are combined into a single larger card
State Tracking
In Rummage, card states are tracked using components:
#![allow(unused)] fn main() { pub struct CardState { pub tapped: bool, pub face_down: bool, pub flipped: bool, pub phased_out: bool, pub transformed: bool, // Other states } }
Rules for State Changes
State changes follow specific rules:
- Tapping: Usually happens as a cost or an effect
- Untapping: Normally happens during the untap step
- Face-down: Usually through effects like Morph
- Flipping: Only happens through specific card effects
State Interaction with Game Rules
Card states interact with game rules in various ways:
- Tapped creatures can't attack or use tap abilities
- Face-down creatures are 2/2 creatures with no text, name, or types
- Phased-out cards are treated as though they don't exist
Visual Representation
For details on how these states are visually represented in the game UI, see Card States Visualization.
Mana and Costs
This document provides an overview of the rules for mana and costs in Magic: The Gathering. For implementation details in Rummage, see the relevant code in the mana.rs file.
Mana Types
Magic: The Gathering has six types of mana:
- White (W)
- Blue (U)
- Black (B)
- Red (R)
- Green (G)
- Colorless (C)
Additionally, there is the concept of generic mana, represented by numbers (e.g., {1}, {2}), which can be paid with any type of mana.
Mana Costs
A spell's mana cost appears in the upper right corner of the card and consists of mana symbols. For example:
- {2}{U} means "two generic mana and one blue mana"
- {W}{W} means "two white mana"
- {X}{R}{R} means "X generic mana and two red mana" where X is chosen by the player
Mana Pool
Players have a mana pool where mana is stored until spent or until a phase or step ends. Mana in a player's mana pool can be spent to pay costs or may be lost when a phase or step ends.
Mana Sources
Mana is produced by mana abilities, which come from various sources:
- Land abilities: Basic lands and many nonbasic lands
- Creature abilities: Mana dorks and other creatures
- Artifact abilities: Mana rocks and other artifacts
- Enchantment abilities: Various enchantments
- Spells: Some instants and sorceries produce mana
Additional Costs
Many spells and abilities have additional costs beyond their mana cost:
- Tap costs: {T} means "tap this permanent"
- Life costs: "Pay N life"
- Sacrifice costs: "Sacrifice a creature"
- Discard costs: "Discard a card"
- Exile costs: "Exile a card from your graveyard"
Alternative Costs
Some spells can be cast for alternative costs:
- Flashback: Cast from graveyard for a different cost
- Overload: Cast with a different effect for a different cost
- Foretell: Cast from exile for a different cost
- Evoke: Cast with a sacrifice trigger for a reduced cost
Cost Reduction
Various effects can reduce costs:
- "Spells you cast cost {1} less to cast"
- "This spell costs {1} less to cast for each artifact you control"
- "The first creature spell you cast each turn costs {2} less to cast"
Cost Increases
Similarly, effects can increase costs:
- "Spells your opponents cast cost {1} more to cast"
- "Activated abilities cost {2} more to activate"
- "Creature spells with flying cost {1} more to cast"
Special Mana Types
Beyond the basic types, there are special types of mana:
- Snow mana ({S}): Produced by snow permanents
- Phyrexian mana ({W/P}, {U/P}, etc.): Can be paid with either colored mana or 2 life
- Hybrid mana ({W/U}, {B/R}, etc.): Can be paid with either of two colors
- Colorless-specific mana ({C}): Must be paid with colorless mana, not any color
Mana Conversion
Mana conversion for costs follows these rules:
- Colored mana requirements must be paid with the exact color
- Generic mana can be paid with any type of mana
- Colorless-specific requirements must be paid with colorless mana
- Special mana symbols follow their own rules
Related Documentation
For more information on how mana and costs are implemented in Rummage, see:
- Casting Spells: How mana is used to cast spells
- Abilities: How mana abilities work
- Stack: How mana abilities bypass the stack
Triggered Abilities
This document provides an overview of triggered abilities in Magic: The Gathering.
What Are Triggered Abilities?
Triggered abilities are abilities that automatically trigger when certain game events occur. They are written as "When/Whenever/At [event], [effect]."
Examples:
- "When this creature enters the battlefield, draw a card."
- "Whenever an opponent gains life, you may draw a card."
- "At the beginning of your upkeep, you gain 1 life."
Triggers
Triggered abilities can trigger based on:
-
State Changes: "When [state change] occurs..."
- A creature entering the battlefield
- A creature dying
- A creature attacking or blocking
-
Phase/Step Changes: "At the beginning of [phase]..."
- At the beginning of upkeep
- At the beginning of your draw step
- At the beginning of combat
-
Player Actions: "Whenever a player [action]..."
- Whenever a player casts a spell
- Whenever a player plays a land
- Whenever a player activates an ability
Resolution Process
When a triggered ability triggers:
- The ability is put on the stack the next time a player would receive priority
- If multiple abilities trigger at the same time, they go on the stack in APNAP order (Active Player, Non-Active Player)
- If multiple abilities trigger for a single player, that player chooses the order
- Triggered abilities resolve like any other ability on the stack
Special Types of Triggered Abilities
Delayed Triggered Abilities
These set up an effect that will happen later:
- "At the beginning of the next end step, return the exiled card to its owner's hand."
- "At the beginning of your next upkeep, sacrifice this permanent."
State Triggers
These continually check if a condition is true:
- "When you have no cards in hand, sacrifice this permanent."
- "When you control no creatures, sacrifice this enchantment."
Implementation Note
For the detailed implementation of triggered abilities in Rummage, including code examples and integration with other systems, see:
- Abilities: General abilities documentation
- Triggered Ability Implementation: Format-specific details
These documents provide technical details on how triggered abilities are handled in the game engine.
Replacement Effects
This document provides an overview of replacement effects in Magic: The Gathering.
What Are Replacement Effects?
Replacement effects are continuous effects that watch for a particular event to occur and completely or partially replace that event with a different event. They use the words "instead of," "rather than," or "skip."
Examples:
- "If you would draw a card, instead draw two cards."
- "If a creature would die, exile it instead."
- "If damage would be dealt to you, prevent that damage. You gain that much life instead."
How Replacement Effects Work
Replacement effects modify events before they occur:
- They don't use the stack and can't be responded to
- They apply before the event happens (not after)
- Only one replacement effect can apply to a particular event
- If multiple replacement effects could apply, the affected player or controller of the affected object chooses which to apply first
- After applying one replacement effect, the resulting event is checked again for other applicable replacement effects
Types of Replacement Effects
Self-Replacement Effects
These modify how a spell or ability resolves:
- "Draw three cards. You lose 3 life unless you discard a card for each card drawn this way."
Prevention Effects
A special type of replacement effect that prevents damage:
- "Prevent all damage that would be dealt to you and creatures you control this turn."
- "If a source would deal damage to target creature, prevent 3 of that damage."
Redirection Effects
These change where an effect applies:
- "If a source would deal damage to you, it deals that damage to target creature instead."
Multiple Replacement Effects
When multiple replacement effects could apply to the same event:
- The affected player (or controller of the affected object) chooses which to apply first
- After applying one effect, check if any remaining replacement effects still apply
- If so, the player chooses among those, and so on
- This continues until no more applicable replacement effects remain
Implementation Note
For information on the implementation of replacement effects in Rummage, see:
- Effects: General effects documentation, if available
- Static Abilities: Replacement effects are often implemented as static abilities
These documents provide technical details on how replacement effects are handled in the game engine.
Subgames and Game Restarting
Subgames
In Magic: The Gathering, a "subgame" is a complete game of Magic played within another game, most notably created by the card Shahrazad. Subgames have their own distinct game state, separate from the main game.
Shahrazad Implementation
Shahrazad is a sorcery that instructs players to leave the current game temporarily and play a subgame with their libraries as their decks. When the subgame concludes, the main game resumes, and the loser of the subgame loses half their life, rounded up.
Key implementation details:
- The subgame creates a completely new game state with its own zones, life totals, etc.
- Cards used in the subgame come from the players' libraries in the main game
- Cards that leave the subgame (exile, graveyard, etc.) remain outside the subgame
- When the subgame ends, all cards still in the subgame return to their owners' libraries in the main game
- The main game's state is suspended but preserved entirely during the subgame
Technical Implementation
Our implementation of subgames utilizes a stack-based approach:
- When a subgame is created, we push the current game state onto a stack
- A new game state is initialized for the subgame with appropriate starting conditions
- The subgame runs as a complete game with its own turn structure and rules
- When the subgame concludes, we pop the previous game state from the stack and resume
- We apply any results from the subgame to the main game (like life loss)
Subgames can be nested, meaning a Shahrazad could be cast within a subgame created by another Shahrazad, creating sub-subgames.
Game Restarting
Some cards, like Karn Liberated, have the ability to restart the game. This differs from subgames in that the current game ends completely and a new game begins with modified starting conditions.
Karn Liberated Implementation
Karn Liberated's ultimate ability (-14 loyalty) restarts the game, with all cards in exile that were exiled with Karn put into their owners' hands in the new game.
Key implementation details:
- The current game ends immediately
- A new game begins with players at their starting life totals
- Players draw new starting hands
- Cards exiled with Karn's ability are returned to their owners' hands
- All other cards return to their starting zones (library, command zone, etc.)
Technical Implementation
Our restart implementation:
- Tracks cards exiled with specific abilities like Karn's
- When a restart ability is triggered, saves references to the tracked exile cards
- Cleans up all game resources from the current game
- Initializes a new game with standard starting conditions
- Modifies the initial state to include returned exiled cards in their owners' hands
Differences Between Subgames and Restarting
Feature | Subgames | Game Restarting |
---|---|---|
Original game state | Preserved | Ended completely |
Players' life | Unchanged in main game | Reset to starting amount |
Cards from old game | Only library cards | All cards return to starting zones |
Continuity | Returns to main game when done | Old game ended, only some cards carried over |
Implementation | Stack-based state management | Complete reinitialization |
Integration with Game Engine
Both features leverage our snapshot system for state management:
- Subgames use the snapshot system to save and restore the main game state
- Game restarting uses snapshots to track exiled cards before reinitializing
See the Snapshot System documentation for more details on how state is managed for these complex mechanics.
Priority System
Overview
The priority system is a fundamental mechanism in Magic: The Gathering that determines when players can cast spells and activate abilities. This document explains how priority is implemented in Rummage and how it controls the flow of gameplay.
Priority Basics
In Magic: The Gathering, priority is the right to take an action such as casting a spell, activating an ability, or making a special game action. The priority system follows these key principles:
- The active player receives priority at the beginning of each step and phase, except for the untap step and most cleanup steps
- A player with priority may take an action or pass priority
- When all players pass priority in succession without taking an action, the top object on the stack resolves or, if the stack is empty, the current step or phase ends
- Whenever an object is put on the stack, all players pass priority, and the stack resolves, priority is given to the active player
Implementation in Rummage
In Rummage, the priority system is implemented using a combination of resources and systems:
#![allow(unused)] fn main() { /// Resource tracking priority state #[derive(Resource)] pub struct PrioritySystem { /// Current player with priority pub current_priority: Option<Entity>, /// Players who have passed priority without taking an action pub passed_players: HashSet<Entity>, /// Whether the game is currently waiting for priority actions pub waiting_for_priority: bool, /// The last player to take an action pub last_action_player: Option<Entity>, } /// Component marking the active player #[derive(Component)] pub struct ActivePlayer; }
Priority Assignment
Priority is assigned according to turn order, starting with the active player:
#![allow(unused)] fn main() { pub fn assign_priority_system( mut priority_system: ResMut<PrioritySystem>, turn_system: Res<TurnSystem>, game_state: Res<GameState>, active_player_query: Query<Entity, With<ActivePlayer>>, ) { // Only assign priority if not already waiting for someone if priority_system.waiting_for_priority { return; } // When a phase or step begins, active player gets priority if game_state.pending_phase_change { let active_player = active_player_query.get_single().unwrap(); priority_system.current_priority = Some(active_player); priority_system.passed_players.clear(); priority_system.waiting_for_priority = true; } // After stack resolution, active player gets priority if game_state.stack_just_resolved { let active_player = active_player_query.get_single().unwrap(); priority_system.current_priority = Some(active_player); priority_system.passed_players.clear(); priority_system.waiting_for_priority = true; } } }
Passing Priority
When a player passes priority, it's passed to the next player in turn order:
#![allow(unused)] fn main() { pub fn handle_pass_priority( mut priority_system: ResMut<PrioritySystem>, mut pass_priority_events: EventReader<PassPriorityEvent>, players: Query<(Entity, &Player)>, turn_system: Res<TurnSystem>, mut game_events: EventWriter<GameEvent>, ) { for event in pass_priority_events.iter() { // Only process if this player currently has priority if let Some(current_priority) = priority_system.current_priority { if current_priority == event.player { // Add player to passed list priority_system.passed_players.insert(current_priority); // Find next player in turn order let next_player = get_next_player_in_turn_order( current_priority, &players, &turn_system ); // If next player has already passed, check for all players passing if priority_system.passed_players.contains(&next_player) || priority_system.passed_players.len() == players.iter().count() { // All players have passed priority game_events.send(GameEvent::AllPlayersPassed); priority_system.waiting_for_priority = false; priority_system.current_priority = None; } else { // Pass to next player priority_system.current_priority = Some(next_player); game_events.send(GameEvent::PriorityPassed { from: current_priority, to: next_player, }); } } } } } }
Priority After Actions
After a player takes an action, they receive priority again:
#![allow(unused)] fn main() { pub fn handle_player_action( mut priority_system: ResMut<PrioritySystem>, mut player_action_events: EventReader<PlayerActionEvent>, mut game_events: EventWriter<GameEvent>, ) { for event in player_action_events.iter() { // Record player taking action priority_system.last_action_player = Some(event.player); // Clear passed players list since an action was taken priority_system.passed_players.clear(); // Return priority to the player who took action priority_system.current_priority = Some(event.player); priority_system.waiting_for_priority = true; game_events.send(GameEvent::PriorityAssigned { player: event.player, reason: PriorityReason::AfterAction, }); } } }
Stack Resolution
When all players pass priority, either the top of the stack resolves or the current phase/step ends:
#![allow(unused)] fn main() { pub fn handle_all_players_passed( mut commands: Commands, mut stack: ResMut<Stack>, mut game_state: ResMut<GameState>, mut priority_system: ResMut<PrioritySystem>, active_player_query: Query<Entity, With<ActivePlayer>>, mut game_events: EventWriter<GameEvent>, ) { // Process what happens when all players pass priority if !stack.items.is_empty() { // Resolve top item of stack let top_item = stack.items.pop().unwrap(); game_events.send(GameEvent::StackItemResolved { item_id: top_item.id, }); // Mark for priority reassignment after resolution game_state.stack_just_resolved = true; } else { // Empty stack, end current phase/step game_state.proceed_to_next_phase_or_step(); game_events.send(GameEvent::PhaseStepEnded { phase: game_state.current_phase.clone(), step: game_state.current_step.clone(), }); } } }
Special Priority Rules
Special Timing Rules
Some game actions use special timing rules that modify how priority works:
#![allow(unused)] fn main() { pub fn handle_special_timing( mut commands: Commands, mut priority_system: ResMut<PrioritySystem>, mut special_action_events: EventReader<SpecialActionEvent>, mut game_events: EventWriter<GameEvent>, ) { for event in special_action_events.iter() { match event.action_type { SpecialActionType::PlayLand => { // Playing a land doesn't use the stack and doesn't pass priority // but does count as taking an action for priority purposes priority_system.last_action_player = Some(event.player); priority_system.passed_players.clear(); priority_system.current_priority = Some(event.player); game_events.send(GameEvent::PriorityRetained { player: event.player, reason: "Played a land", }); }, SpecialActionType::ManaAbility => { // Mana abilities don't use the stack and don't change priority game_events.send(GameEvent::ManaAbilityResolved { player: event.player, source: event.source, }); }, // Other special timing rules... } } } }
State-Based Actions
State-based actions are checked before a player would receive priority:
#![allow(unused)] fn main() { pub fn check_state_based_actions( mut commands: Commands, mut priority_system: ResMut<PrioritySystem>, mut game_state: ResMut<GameState>, // Other query parameters... ) { // Only check when a player would receive priority if !priority_system.waiting_for_priority && game_state.stack_just_resolved { // Process all state-based actions let sba_performed = perform_state_based_actions( &mut commands, // Other parameters... ); // If any state-based actions were performed, check again // before assigning priority if sba_performed { game_state.sba_check_needed = true; } else { game_state.sba_check_needed = false; game_state.stack_just_resolved = false; } } } }
Turn-Based Actions
Turn-based actions happen automatically at specific points regardless of priority:
#![allow(unused)] fn main() { pub fn handle_turn_based_actions( mut commands: Commands, game_state: Res<GameState>, mut turn_action_events: EventWriter<TurnBasedActionEvent>, ) { match game_state.current_phase { Phase::Beginning if game_state.current_step == Step::Draw => { // Draw step: Active player draws a card turn_action_events.send(TurnBasedActionEvent::ActivePlayerDraws); }, Phase::Combat if game_state.current_step == Step::EndOfCombat => { // End of combat: Remove all creatures from combat turn_action_events.send(TurnBasedActionEvent::RemoveFromCombat); }, // Other turn-based actions... _ => {} } } }
UI Integration
The priority system is visually represented to players:
#![allow(unused)] fn main() { pub fn update_priority_ui( priority_system: Res<PrioritySystem>, stack: Res<Stack>, players: Query<(Entity, &Player, &PlayerName)>, mut ui_state: ResMut<UiState>, ) { if let Some(current_priority) = priority_system.current_priority { // Highlight player with priority if let Ok((_, _, name)) = players.get(current_priority) { ui_state.priority_indicator = Some(PriorityIndicator { player: current_priority, name: name.0.clone(), has_passed: false, }); } } else { ui_state.priority_indicator = None; } // Update UI for players who have passed for entity in &priority_system.passed_players { if let Ok((_, _, name)) = players.get(*entity) { ui_state.passed_priority_indicators.push(PriorityIndicator { player: *entity, name: name.0.clone(), has_passed: true, }); } } // Update stack indicator ui_state.stack_size = stack.items.len(); } }
Multiplayer Priority
In multiplayer games, priority follows turn order (APNAP - Active Player, Non-Active Player):
#![allow(unused)] fn main() { pub fn get_next_player_in_turn_order( current_player: Entity, players: &Query<(Entity, &Player)>, turn_system: &Res<TurnSystem>, ) -> Entity { let turn_order = &turn_system.player_order; let current_index = turn_order.iter() .position(|&p| p == current_player) .unwrap_or(0); // Get next player, wrapping around to the beginning let next_index = (current_index + 1) % turn_order.len(); turn_order[next_index] } }
Testing Priority Flow
The priority system is tested through a variety of scenarios:
#![allow(unused)] fn main() { #[test] fn test_priority_basic_flow() { // Setup test environment let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_system(assign_priority_system) .add_system(handle_pass_priority) .add_system(handle_player_action) .add_system(handle_all_players_passed) // Other test setup... // Create test players let player1 = spawn_test_player(&mut app.world, "Player 1"); let player2 = spawn_test_player(&mut app.world, "Player 2"); // Set active player app.world.entity_mut(player1).insert(ActivePlayer); // Begin test phase let mut game_state = app.world.resource_mut::<GameState>(); game_state.pending_phase_change = true; // Run systems app.update(); // Verify active player got priority let priority_system = app.world.resource::<PrioritySystem>(); assert_eq!(priority_system.current_priority, Some(player1)); // Simulate player1 passing priority app.world.resource_mut::<Events<PassPriorityEvent>>() .send(PassPriorityEvent { player: player1 }); // Run systems app.update(); // Verify priority passed to player2 let priority_system = app.world.resource::<PrioritySystem>(); assert_eq!(priority_system.current_priority, Some(player2)); // More test assertions... } }
Edge Cases
The priority system handles several edge cases:
Split Second
The "split second" keyword prevents players from casting spells or activating non-mana abilities while a spell with split second is on the stack:
#![allow(unused)] fn main() { pub fn handle_split_second( stack: Res<Stack>, mut priority_system: ResMut<PrioritySystem>, mut player_action_events: EventReader<PlayerActionEvent>, mut game_events: EventWriter<GameEvent>, ) { // Check if a split second spell is on the stack let split_second_active = stack.items.iter().any(|item| { if let StackItemType::Spell { abilities, .. } = &item.item_type { abilities.contains(&Ability::SplitSecond) } else { false } }); if split_second_active { // Only allow certain actions while split second is active for event in player_action_events.iter() { match event.action_type { ActionType::ActivateManaAbility(_) => { // Mana abilities are allowed // Process normally }, ActionType::TriggerSpecialAction(SpecialActionType::MorphFaceDown) => { // Special actions like turning a face-down creature face up are allowed // Process normally }, _ => { // All other actions are denied game_events.send(GameEvent::ActionDenied { player: event.player, action_type: event.action_type.clone(), reason: "Split second prevents this action", }); continue; } } } } } }
No Action Possible
If a player cannot take any action, they must pass priority:
#![allow(unused)] fn main() { pub fn handle_no_action_possible( mut priority_system: ResMut<PrioritySystem>, players: Query<(Entity, &Player, &Hand)>, mut pass_priority_events: EventWriter<PassPriorityEvent>, mut game_events: EventWriter<GameEvent>, ) { if let Some(current_priority) = priority_system.current_priority { if let Ok((entity, player, hand)) = players.get(current_priority) { // Check if player can take any action if hand.cards.is_empty() && !player_has_playable_permanents(entity) { // Player has no possible actions, auto-pass priority pass_priority_events.send(PassPriorityEvent { player: entity }); game_events.send(GameEvent::AutoPassPriority { player: entity, reason: "No possible actions", }); } } } } }
Conclusion
The priority system is a fundamental aspect of Magic: The Gathering that controls the flow of game actions. Rummage's implementation carefully follows the official rules, ensuring that players can take actions in the correct order and that game state advances properly.
Next: Stack
Layer System
Overview
The Layer System is one of Magic: The Gathering's most complex rule mechanisms, designed to handle the application of continuous effects in a consistent, predictable order. This document explains how the Layer System is implemented in Rummage and how it resolves potentially conflicting continuous effects.
The Seven Layers
According to the MTG Comprehensive Rules (section 613), continuous effects are applied in a specific order across seven distinct layers:
- Copy Effects - Effects that modify how an object is copied
- Control-Changing Effects - Effects that change control of an object
- Text-Changing Effects - Effects that change an object's text
- Type-Changing Effects - Effects that change an object's types, subtypes, or supertypes
- Color-Changing Effects - Effects that change an object's colors
- Ability-Adding/Removing Effects - Effects that add or remove abilities
- Power/Toughness-Changing Effects - Effects that modify a creature's power and/or toughness
Within the Power/Toughness layer (layer 7), effects are applied in this sub-order:
- 7a. Effects from characteristic-defining abilities
- 7b. Effects that set power and/or toughness to a specific value
- 7c. Effects that modify power and/or toughness (but don't set them)
- 7d. Effects from counters on the creature
- 7e. Effects that switch power and toughness
Implementation in Rummage
In Rummage, the Layer System is implemented using a combination of components, resources, and systems:
#![allow(unused)] fn main() { /// Resource that manages continuous effects #[derive(Resource)] pub struct ContinuousEffectsSystem { pub effects: Vec<ContinuousEffect>, pub timestamp_counter: u64, } /// Representation of a continuous effect #[derive(Clone, Debug)] pub struct ContinuousEffect { pub id: Uuid, pub source: Entity, pub layer: Layer, pub effect_type: ContinuousEffectType, pub targets: EffectTargets, pub duration: EffectDuration, pub timestamp: u64, pub dependency_info: Option<DependencyInfo>, } /// Enumeration of the different layers #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum Layer { CopyEffects = 1, ControlChangingEffects = 2, TextChangingEffects = 3, TypeChangingEffects = 4, ColorChangingEffects = 5, AbilityAddingEffects = 6, PowerToughnessCharDefining = 7, PowerToughnessSetValue = 8, PowerToughnessModify = 9, PowerToughnessCounters = 10, PowerToughnessSwitch = 11, } }
Application Process
Continuous effects are applied through a multi-stage process:
#![allow(unused)] fn main() { pub fn apply_continuous_effects_system( mut commands: Commands, effects_system: Res<ContinuousEffectsSystem>, mut card_query: Query<(Entity, &mut Card, &mut ContinuousEffectsApplied)>, // Other query parameters... ) { // Group effects by layer let mut effects_by_layer: HashMap<Layer, Vec<&ContinuousEffect>> = HashMap::new(); for effect in &effects_system.effects { if effect.is_active() { effects_by_layer.entry(effect.layer) .or_insert_with(Vec::new) .push(effect); } } // Sort each layer's effects by timestamp for effects in effects_by_layer.values_mut() { effects.sort_by_key(|effect| effect.timestamp); } // Apply effects layer by layer for layer in Layer::iter() { if let Some(effects) = effects_by_layer.get(&layer) { apply_layer_effects(layer, effects, &mut commands, &mut card_query); } } } }
Dependency Handling
One of the most intricate parts of the Layer System is handling dependencies between effects. According to the rules, if applying one effect would change what another effect does or what it applies to, there's a dependency:
#![allow(unused)] fn main() { pub fn detect_dependencies(effects: &mut [ContinuousEffect]) { for i in 0..effects.len() { for j in 0..effects.len() { if i != j { if does_effect_depend_on( &effects[i], &effects[j], // Other parameters to check dependency ) { effects[i].dependency_info = Some(DependencyInfo { depends_on: effects[j].id, }); } } } } // Reorder effects based on dependencies reorder_effects_by_dependencies(effects); } }
Layer System Examples
Example 1: Layers 7b and 7c Interaction
Consider these two effects:
- Humility (Layer 7b): "All creatures lose all abilities and have base power and toughness 1/1."
- Glorious Anthem (Layer 7c): "Creatures you control get +1/+1."
#![allow(unused)] fn main() { // First, in layer 7b, set all creatures to 1/1 for (entity, mut card, _) in creature_query.iter_mut() { if let CardDetails::Creature(ref mut creature) = card.details.details { // Apply Humility effect (Layer 7b - sets P/T) creature.power = 1; creature.toughness = 1; } } // Then, in layer 7c, apply the +1/+1 bonus to controlled creatures for (entity, mut card, controller) in creature_query.iter_mut() { if controller.controller == anthem_controller { if let CardDetails::Creature(ref mut creature) = card.details.details { // Apply Glorious Anthem effect (Layer 7c - modifies P/T) creature.power += 1; creature.toughness += 1; } } } }
Example 2: Dependency Between Layers
Consider these effects:
- Conspiracy (Layer 4): "All creatures in your hand, library, graveyard, and battlefield are Elves."
- Elvish Champion (Layer 6): "All Elves get +1/+1 and have forestwalk."
Here, the effect of Elvish Champion depends on what creatures are Elves, which is affected by Conspiracy:
#![allow(unused)] fn main() { // Detect the dependency pub fn detect_type_dependent_abilities(effects: &mut [ContinuousEffect]) { for i in 0..effects.len() { for j in 0..effects.len() { if i != j { let effect_i = &effects[i]; let effect_j = &effects[j]; // If effect_i adds abilities to a specific type if let ContinuousEffectType::AddAbilities { type_requirement: Some(type_req), .. } = &effect_i.effect_type { // And effect_j changes types if let ContinuousEffectType::ChangeTypes { .. } = &effect_j.effect_type { // Then effect_i depends on effect_j effects[i].dependency_info = Some(DependencyInfo { depends_on: effect_j.id, }); } } } } } } }
Timestamp Ordering
When multiple effects apply in the same layer and don't have dependencies, they're applied in timestamp order:
#![allow(unused)] fn main() { pub fn add_continuous_effect( mut effects_system: ResMut<ContinuousEffectsSystem>, effect_data: ContinuousEffectData, ) { let timestamp = effects_system.timestamp_counter; effects_system.timestamp_counter += 1; let effect = ContinuousEffect { id: Uuid::new_v4(), layer: effect_data.layer, effect_type: effect_data.effect_type, // Other fields... timestamp, dependency_info: None, }; effects_system.effects.push(effect); } }
Characteristic-Defining Abilities (CDAs)
Layer 7a handles characteristic-defining abilities, which are abilities that define a creature's power/toughness, like Tarmogoyf's "/+1 equal to the number of card types in all graveyards":
#![allow(unused)] fn main() { pub fn apply_characteristic_defining_abilities( mut card_query: Query<(Entity, &mut Card, &CardAbilities)>, ) { for (entity, mut card, abilities) in card_query.iter_mut() { for ability in &abilities.characteristic_defining { match ability { CharacteristicDefiningAbility::PowerToughness(calculator) => { if let CardDetails::Creature(ref mut creature) = card.details.details { let (power, toughness) = calculator(entity); creature.power = power; creature.toughness = toughness; } }, // Other CDA types... } } } } }
Testing the Layer System
Testing the layer system is complex due to the numerous potential interactions. Rummage uses a combination of unit tests for each layer and integration tests for complex scenarios:
#![allow(unused)] fn main() { #[test] fn test_layers_example_scenario() { // Setup test environment let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_systems(Update, apply_continuous_effects_system) .init_resource::<ContinuousEffectsSystem>(); // Create test cards and effects let humility = spawn_humility(&mut app.world); let anthem = spawn_anthem(&mut app.world); let test_creature = spawn_test_creature(&mut app.world, 2, 2); // Run systems to apply effects app.update(); // Verify the combined effect: creature should be 2/2 let (_, card, _) = app.world .query::<(Entity, &Card, &ContinuousEffectsApplied)>() .get(&app.world, test_creature) .unwrap(); if let CardDetails::Creature(creature) = &card.details.details { assert_eq!(creature.power, 2); assert_eq!(creature.toughness, 2); } else { panic!("Expected a creature!"); } } }
Edge Cases and Advanced Interactions
The layer system must handle many edge cases:
Self-Replacement Effects
When an effect modifies how itself works:
#![allow(unused)] fn main() { pub fn handle_self_replacement_effects(effects: &mut [ContinuousEffect]) { // Identify and mark self-replacement effects let mut self_replacing = Vec::new(); for (idx, effect) in effects.iter().enumerate() { if is_self_replacing(effect) { self_replacing.push(idx); } } // Apply them in a special order // ... } }
Copiable Values
Layer 1 deals with what values are copied when an effect copies an object:
#![allow(unused)] fn main() { pub fn determine_copiable_values( source: Entity, target: Entity, card_query: &Query<(Entity, &Card)>, ) -> Card { // Get the base card to copy let (_, source_card) = card_query.get(source).unwrap(); // Create a copy with only the copiable values Card { name: source_card.name.clone(), cost: source_card.cost.clone(), type_info: source_card.type_info.clone(), details: source_card.details.clone(), rules_text: source_card.rules_text.clone(), keywords: KeywordAbilities::default(), // Keywords aren't copied in layer 1 } } }
Conclusion
The Layer System is one of the most complex parts of the Magic: The Gathering rules, but its step-by-step approach ensures that continuous effects are applied consistently. Rummage's implementation carefully follows these rules to ensure games play out correctly, even in the most complex scenarios.
Next: Triggered Abilities
Contribution Guidelines
This document provides an overview of how to contribute to the Rummage project. For detailed guidelines on specific aspects of contribution, please refer to the following resources:
- Documentation Guide - Guidelines for contributing to documentation
- Git Workflow Guidelines - Recommended git workflow and commit message format
- Testing Guide - Guidelines for writing and running tests
We welcome contributions from everyone. By participating in this project, you agree to abide by our code of conduct.
Getting Started
- Fork the repository
- Clone your fork locally
- Set up the development environment
- Create a new branch for your feature or bug fix
- Make your changes following our coding standards
- Write or update tests as needed
- Commit your changes with clear, descriptive commit messages (see Git Workflow Guidelines)
- Push your branch to your fork
- Submit a pull request to the main repository
Pull Request Process
- Ensure your code passes all tests and linting checks
- Update documentation as necessary
- Include a clear description of the changes in your pull request
- Link any related issues using the appropriate GitHub syntax
- Wait for a maintainer to review your pull request
- Address any feedback provided by reviewers
Thank you for contributing to Rummage!
Documentation Guidelines
This page contains the comprehensive documentation guidelines for the Rummage MTG Commander game engine built with Bevy 0.15.x.
Documentation Structure
The documentation is organized into the following major sections:
- Commander Rules - Implementation of MTG Commander format rules and mechanics
- Game UI - User interface systems and components
- Networking - Multiplayer functionality using bevy_replicon
Contributing to Documentation
When contributing to the documentation, please follow these guidelines:
Document Structure
Each document should have:
- A clear, descriptive title
- A brief introduction explaining the document's purpose
- A table of contents for documents longer than a few paragraphs
- Properly nested headings (H1 -> H2 -> H3)
- Code examples where appropriate
- Cross-references to related documents
- Implementation status indicators where appropriate
Style Guide
- Use American English spelling and grammar
- Write in a clear, concise style
- Use active voice where possible
- Keep paragraphs focused on a single topic
- Use proper Markdown formatting:
#
for headings (not underlines)- Backticks for code snippets
- Triple backticks for code blocks with language identifier
- Dashes for unordered lists
- Numbers for ordered lists
Code Examples
Include meaningful code examples that:
- Use non-deprecated Bevy 0.15.x APIs
- Demonstrate the concept being explained
- Are syntactically correct
- Include comments for complex code
- Use Rust syntax highlighting with ````rust`
Implementation Status
Use the following indicators to mark implementation status:
- ✅ Implemented and tested
- 🔄 In progress
- ⚠️ Planned but not yet implemented
Building the Documentation
We use mdbook
to build the documentation. To get started:
-
Install required tools:
make install-tools
-
Build the documentation:
make build
-
View the documentation locally:
make serve
Then open your browser to http://localhost:3000
Documentation Maintenance
Use the following make commands for documentation maintenance:
make lint
- Check for style issuesmake toc
- Generate table of contentsmake check
- Check for broken linksmake validate
- Validate documentation structuremake update-dates
- Update last modified dates
Questions?
If you have questions about the documentation, please contact the documentation team or open an issue in the project repository.
Git Workflow Guidelines
This document outlines the recommended git workflow for contributing to the Rummage project, with a special focus on writing clear, informative commit messages.
Commit Message Guidelines
Good commit messages are essential for maintaining a clean, understandable project history. They help with:
- Understanding changes at a glance
- Generating accurate changelogs
- Tracking the evolution of features and bug fixes
- Making code reviews more efficient
- Facilitating future maintenance and debugging
Commit Message Structure
Each commit message should follow this structure:
<type>: <subject>
<body>
<footer>
Type
Begin your commit message with one of these types to categorize the change:
Type | Description |
---|---|
feat | A new feature |
fix | A bug fix |
docs | Documentation changes |
style | Code style changes (formatting, indentation) |
refactor | Code refactoring (no functional changes) |
perf | Performance improvements |
test | Adding or updating tests |
chore | Maintenance tasks, build changes, etc. |
ci | CI/CD related changes |
Subject
The subject line is a brief summary of the change:
- Write in imperative mood (e.g., "Add" not "Added" or "Adds")
- Keep it under 50 characters
- Don't end with a period
- Capitalize the first letter
Body
The commit body provides more detailed information:
- Separate from subject with a blank line
- Explain the "what" and "why" (not "how")
- Use bullet points when appropriate (- or *)
- Wrap text at ~72 characters
- Reference issues and user stories when applicable
Footer
The footer contains metadata about the commit:
- Reference issues with "Fixes #123" or "Closes #123"
- Breaking changes should be noted with "BREAKING CHANGE:"
Examples
Here are examples of well-formatted commit messages:
feat: Add user authentication system
Implement JWT-based authentication flow with refresh tokens.
- Add login/logout endpoints
- Create token generation service
- Add middleware for protected routes
Closes #45
fix: Prevent race condition in database connection pool
When multiple requests arrived simultaneously during
initialization, connections were being created beyond
the configured maximum. Added mutex lock to ensure
proper counting.
Fixes #123
docs: Update API documentation with authentication examples
Add code samples for all authentication flows and
improve explanations of token usage.
Best Practices
Commit Organization
- When possible, limit commits to a single logical change
- Small, focused commits are easier to understand and review
- Avoid mixing multiple unrelated changes in a single commit
- When incorporating feedback from PR reviews, reference the PR number
Working with Branches
-
Create feature branches from the main branch
git checkout -b feature/my-new-feature main
-
Make regular, focused commits
git commit -m "feat: Add validation for user input fields"
-
Rebase your branch on main before submitting a PR
git checkout main git pull git checkout feature/my-new-feature git rebase main
-
Squash commits if needed to maintain a clean history
git rebase -i HEAD~3 # Squash last 3 commits
Pull Request Guidelines
- Ensure PR title follows the same commit message format
- Include a detailed description of changes
- Reference related issues
- Ensure all tests pass
- Request reviews from appropriate team members
Git Configuration Tips
You can configure git to use your preferred editor for commit messages:
git config --global core.editor "code --wait" # For VS Code
For multi-line commit messages, use:
git commit # Opens editor for detailed message
Conclusion
Following these commit message guidelines helps maintain a high-quality codebase with a clear, navigable history. Good commit messages are a form of documentation that helps future contributors (including your future self) understand the evolution of the project.
Glossary
This glossary provides definitions for both Magic: The Gathering game terms and Rummage-specific technical terms.
A
Ability
A feature of a card that gives it certain characteristics or allows it to perform certain actions. There are three main types: activated, triggered, and static abilities.
Activated Ability
An ability that a player can activate by paying its cost (e.g., tapping the card, paying mana).
Active Player
The player whose turn it is currently.
APNAP Order
The order in which players make decisions when multiple players need to take actions: Active Player, Non-Active Player.
Attachment
A card or effect that modifies another card (e.g., Auras, Equipment).
B
Battlefield
The zone where permanents (lands, creatures, artifacts, enchantments, and planeswalkers) exist while in play.
Bevy
The game engine used to build Rummage, featuring an entity-component-system (ECS) architecture.
Bevy ECS
The entity-component-system architecture used in Bevy, which separates entities (objects), components (data), and systems (logic).
Bundle
(Bevy) A collection of components that are commonly added to an entity together. In Bevy 0.15.x, many bundles are deprecated in favor of individual components.
C
Card Type
The classification of a Magic card (e.g., creature, instant, sorcery, land).
Cast
To play a spell by putting it on the stack and paying its costs.
Color Identity
The colors in a card's mana cost, color indicator, and rules text. In Commander, a card can only be included in a deck if its color identity is within the commander's color identity.
Combat Phase
The phase of a turn where creatures can attack and block, consisting of Beginning of Combat, Declare Attackers, Declare Blockers, Combat Damage, and End of Combat steps.
Commander
- The legendary creature or planeswalker that leads a Commander deck.
- A casual multiplayer format of Magic where each player builds a 100-card deck around a legendary creature or planeswalker.
Commander Tax
The additional cost of {2} that must be paid for each time a commander has been cast from the command zone.
Component
(Bevy) A piece of data that can be attached to an entity, representing a specific aspect of that entity.
Context
(Rummage) An object containing information about the current game state, used when resolving effects.
Counter (noun)
A marker placed on a card to track various characteristics (e.g., +1/+1 counters, loyalty counters).
Counter (verb)
To respond to a spell or ability by preventing it from resolving.
D
Damage
A reduction in a creature's toughness or a player's life total. Unlike loss of life, damage can be prevented.
Deck
A collection of cards a player brings to the game. In Commander, decks consist of 99 cards plus a commander.
Deserialization
(Rummage) The process of converting serialized data back into game objects, used for loading games or processing network data.
Deterministic RNG
(Rummage) A random number generator that produces the same sequence of values when initialized with the same seed, crucial for networked gameplay.
Detrimental
(Rummage) When using Bevy's component lifetimes system, a tag indicating that an entity can exist without the marked component.
E
Effect
The result of a spell or ability resolving, which may change the game state.
Entity
(Bevy) A unique identifier that can have components attached to it, representing objects in the game.
Entity Component System (ECS)
(Bevy) A software architectural pattern that separates identity (entities), data (components), and behavior (systems).
Exile
A zone where cards removed from the game are placed.
F
Flash
A keyword ability that allows a player to cast a spell any time they could cast an instant.
Floating Mana
Mana in a player's mana pool that hasn't been spent yet. It disappears at the end of steps and phases.
G
Game State
The complete status of the game at a given moment, including all zones, cards, and player information.
GlobalTransform
(Bevy) A component that stores the absolute position, rotation, and scale of an entity in world space.
Graveyard
The zone where cards go when they're destroyed, sacrificed, discarded, or countered.
H
Hand
The zone where players hold cards they've drawn but haven't yet played.
Haste
A keyword ability that allows a creature to attack and use tap abilities the turn it comes under a player's control.
I
Indestructible
A keyword ability that prevents a permanent from being destroyed by damage or effects that say "destroy."
Instant
A card type that can be cast at any time, even during an opponent's turn.
IsServer
(Rummage) A resource indicating whether the current instance is running as a server or client.
K
Keyword Ability
An ability that is represented by a single word or phrase (e.g., Flying, Trample, Deathtouch).
L
Library
The zone where a player's deck is kept during the game.
Life Total
The amount of life a player has. In Commander, players typically start with 40 life.
Legendary
A supertype that restricts a player to controlling only one copy of a specific legendary permanent at a time.
M
Mana
The resource used to cast spells and activate abilities, represented by the five colors (White, Blue, Black, Red, Green) and colorless.
Mana Cost
The amount and type of mana required to cast a spell, shown in the upper right corner of a card.
Mana Pool
A holding area where mana exists from the time it's generated until it's spent or lost.
Marker Component
(Bevy) A component with no data that is used to tag entities for queries or to indicate a state.
Mulligan
The act of drawing a new hand at the start of the game, with one fewer card each time.
N
NetworkConfig
(Rummage) A resource containing configuration for networking, such as ports and connection settings.
Non-Active Player
Any player whose turn it is not.
P
Permanent
A card or token on the battlefield: creatures, artifacts, enchantments, lands, and planeswalkers.
PendingSnapshots
(Rummage) A resource that tracks snapshots waiting to be processed.
Phase
A segment of a turn. Each turn consists of five phases: Beginning, Precombat Main, Combat, Postcombat Main, and Ending.
Plugin
(Bevy) A module that adds specific functionality to the game, typically containing resources, components, and systems.
Priority
The right to take game actions, which passes between players throughout the turn.
Q
Query
(Bevy) A way to access entities and their components in systems, optionally filtered by component types.
R
Replicate
(Rummage/Replicon) A component that marks an entity for network replication.
Replicon
(Rummage) The networking library used for multiplayer functionality.
Resource
(Bevy) Global data not tied to any specific entity, accessed by systems.
Response
Playing a spell or ability after another spell or ability has been put on the stack but before it resolves.
S
Serialization
(Rummage) The process of converting game objects into a format that can be stored or transmitted.
Snapshot
(Rummage) A serialized representation of the game state at a specific point in time.
SnapshotEvent
(Rummage) An event that triggers taking, applying, saving, or loading a game snapshot.
Snapshotable
(Rummage) A marker component indicating that an entity should be included in snapshots.
Sorcery
A card type that can only be cast during a player's main phase when the stack is empty.
Stack
The zone where spells and abilities go while waiting to resolve.
State-Based Action
Game rules that automatically check and update the game state, such as putting creatures with lethal damage into the graveyard.
Step
A subdivision of a phase in a turn.
System
(Bevy) A function that operates on components, implementing game logic.
SystemSet
(Bevy) A collection of systems that can be configured together for ordering and dependencies.
T
Tap
To turn a card sideways to indicate it has been used.
Target
An object or player chosen to be affected by a spell or ability.
Transform
(Bevy) A component that stores the relative position, rotation, and scale of an entity.
Triggered Ability
An ability that automatically triggers when a specific event occurs.
Turn
A full cycle through all phases for a single player.
U
UI
User interface elements that display game information and accept player input.
Untap
To return a tapped card to its upright position, typically done at the beginning of a player's turn.
Update
(Bevy) The main schedule where most game systems run each frame.
W
Winning the Game
In Commander, a player wins by reducing all opponents' life totals to 0, dealing 21 or more combat damage with a single commander to a player, or through alternative win conditions on cards.
World
(Bevy) The container for all entities, components, and resources in the ECS.
Z
Zone
A place where cards can exist during the game: library, hand, battlefield, graveyard, stack, exile, and command.
Note: This glossary is continuously updated as new terms are added to the codebase or as Magic: The Gathering terminology evolves.
Contains info on a Wayland permissions bug and missing symlinks in current wsl2 as of the time of writing. https://github.com/microsoft/WSL/issues/11542
https://github.com/microsoft/wslg/issues/1032#issuecomment-2345292609
WSL2 Audio Issues
To fix audio issues in WSL2:
-
Follow the solution described in this GitHub comment: https://github.com/microsoft/WSL/issues/2187#issuecomment-2605861048
-
Add the following line to your
.bashrc
file:export PULSE_SERVER=unix:/mnt/wslg/PulseServer
-
Source your
.bashrc
file or restart your WSL2 terminal:source ~/.bashrc
-
Install Alsa/Pulse deps
sudo apt install libasound2t64 libasound2-plugins libpulse0
Save/Load System
The save/load system provides robust functionality to persist and restore game states in Rummage's MTG Commander format game engine. This allows games to be saved, loaded, and replayed at any point.
Key Features
- Binary Serialization: Uses
bincode
for efficient and compact save files - Persistent Game State: Complete serialization of game state, players, zones, and commander data
- Game Replays: Support for replaying games from any saved point
- Automatic Saving: Configurable auto-save during state-based action checks
- Save Metadata: Tracking of available save files with timestamps and information
- Entity Mapping: Handles Bevy's entity references through save/load cycles
Use Cases
- Save games for later continuation
- Create game checkpoints before critical decisions
- Analyze past games through replay functionality
- Debug and testing of complex game states
- Share interesting game states with other players
Components
The save/load system consists of several interrelated components:
- Plugin - The
SaveLoadPlugin
managing initialization and registration - Events - Events for triggering save, load, and replay functionality
- Resources - Configuration and state tracking resources
- Data Structures - Serializable representations of game data
- Systems - Bevy systems for handling save/load operations and replay
Getting Started
See the Overview for a quick introduction to using the save/load system, or dive directly into the Implementation Details for more technical information.
For existing projects, the Integration Guide explains how to incorporate save/load functionality into your game systems.
Save/Load System Overview
This document provides a high-level overview of the save/load system in Rummage, explaining its core concepts and usage.
Basic Usage
Saving a Game
To save the current game state, send a SaveGameEvent
:
#![allow(unused)] fn main() { // Save to a specific slot world.send_event(SaveGameEvent { slot_name: "my_save".to_string(), }); }
The save system will:
- Collect all necessary game state information
- Serialize game zones, cards, and commanders
- Use
bevy_persistent
to persist the game state - Write it to the designated save file
- Update metadata with information about the save
Loading a Game
To load a previously saved game, send a LoadGameEvent
:
#![allow(unused)] fn main() { // Load from a specific slot world.send_event(LoadGameEvent { slot_name: "my_save".to_string(), }); }
The load system will:
- Use
bevy_persistent
to load the saved game state - Deserialize the game state data
- Recreate all necessary entities and resources
- Restore the game state, zone contents, and commander data
- Restore all entity relationships and card positions
Automatic Saving
The system includes an automatic save feature that triggers during state-based action checks:
#![allow(unused)] fn main() { // Configure auto-save behavior commands.insert_resource(SaveConfig { save_directory: PathBuf::from("saves"), auto_save_enabled: true, auto_save_frequency: 10, // Save every 10 state-based action checks }); }
Saved Data
The save system captures and restores the following data:
- Game State: Turn number, active player, priority holder, turn order, etc.
- Player Data: Life totals, mana pools, and other player-specific information
- Zone Data: Contents of all game zones (libraries, hands, battlefield, graveyard, etc.)
- Card Positions: Where each card is located in the game state
- Commander Information: Commander assignments, cast counts, and zone locations
Replay Functionality
The replay system allows stepping through a saved game:
#![allow(unused)] fn main() { // Start a replay from a save file world.send_event(StartReplayEvent { slot_name: "my_save".to_string(), }); // Step forward in the replay (multiple steps possible) world.send_event(StepReplayEvent { steps: 1 }); // Stop the current replay world.send_event(StopReplayEvent); }
During replay, the system:
- Loads the initial game state
- Applies recorded actions in sequence
- Updates the visual state of the game
- Allows stepping forward at the user's pace
Save Metadata
The system maintains metadata about all saves in the SaveMetadata
resource using bevy_persistent
:
#![allow(unused)] fn main() { // Access save metadata fn display_saves_system(save_metadata: Res<Persistent<SaveMetadata>>) { for save in &save_metadata.saves { println!("Save: {}, Turn: {}, Time: {}", save.slot_name, save.turn_number, save.timestamp); } } }
Configuration
The save system can be configured via the SaveConfig
resource:
#![allow(unused)] fn main() { let config = SaveConfig { // Directory where save files are stored save_directory: PathBuf::from("custom_saves"), // Whether auto-save is enabled auto_save_enabled: true, // How often auto-saves occur (in state-based action checks) auto_save_frequency: 20, }; }
Entity Serialization
The save/load system handles entity references by converting them to indices during serialization and rebuilding entities during deserialization. This preserves all relationships between entities despite the fact that entity IDs will change between sessions.
Visual Differential Testing
The save/load system includes built-in support for visual differential testing through integration with the snapshot system. This allows for:
- Visual Regression Testing: Compare game renders at different points in time to detect unintended visual changes
- State Verification: Visually confirm that game states are correctly preserved and restored
- Replay Validation: Ensure that replays accurately reproduce the original game visually
Key Features
- Automatic Captures: Images are automatically captured during save operations and replay steps
- Manual Triggers: Press F10 during replay to capture the current visual state
- Programmatic API: Functions to capture and compare game states from code
- Metadata Tracking: Each snapshot is tagged with save slot, turn number, and timestamps
Example Usage
Visual differential testing can be used in both manual testing and automated tests:
#![allow(unused)] fn main() { // In automated tests let result = run_visual_diff_test("test_save_game"); assert!(result.is_ok(), "Visual differences detected"); // In manual testing, press F10 during replay to take snapshots // at points of interest, then compare the resulting images }
Next Steps
- See Implementation for technical details
- Check the API Reference for a complete list of types and functions
- Look at Testing for how to test save/load functionality
Save/Load System Implementation
This document provides technical details about the save/load system implementation in Rummage.
Architecture
The save/load system consists of several interconnected components:
- Plugin:
SaveLoadPlugin
handles registration of all events, resources, and systems. - Events: Events like
SaveGameEvent
andLoadGameEvent
trigger save and load operations. - Resources: Configuration and state tracking resources like
SaveConfig
andReplayState
. - Data Structures: Serializable data representations in the
data.rs
module. - Systems: Bevy systems for handling operations defined in
systems.rs
.
Data Model
The save/load system uses a comprehensive data model to capture the game state:
GameSaveData
The main structure that holds all serialized game data:
#![allow(unused)] fn main() { pub struct GameSaveData { pub game_state: GameStateData, pub players: Vec<PlayerData>, pub zones: ZoneData, pub commanders: CommanderData, pub save_version: String, } }
GameStateData
Core game state information:
#![allow(unused)] fn main() { pub struct GameStateData { pub turn_number: u32, pub active_player_index: usize, pub priority_holder_index: usize, pub turn_order_indices: Vec<usize>, pub lands_played: Vec<(usize, u32)>, pub main_phase_action_taken: bool, pub drawn_this_turn: Vec<usize>, pub eliminated_players: Vec<usize>, pub use_commander_damage: bool, pub commander_damage_threshold: u32, pub starting_life: i32, } }
PlayerData
Player-specific information:
#![allow(unused)] fn main() { pub struct PlayerData { pub id: usize, pub name: String, pub life: i32, pub mana_pool: ManaPool, pub player_index: usize, } }
ZoneData
Information about card zones and contents:
#![allow(unused)] fn main() { pub struct ZoneData { // Maps player indices to their libraries pub libraries: std::collections::HashMap<usize, Vec<usize>>, // Maps player indices to their hands pub hands: std::collections::HashMap<usize, Vec<usize>>, // Shared battlefield pub battlefield: Vec<usize>, // Maps player indices to their graveyards pub graveyards: std::collections::HashMap<usize, Vec<usize>>, // Shared exile zone pub exile: Vec<usize>, // Command zone pub command_zone: Vec<usize>, // Maps card indices to their current zone pub card_zone_map: std::collections::HashMap<usize, Zone>, } }
CommanderData
Commander-specific data:
#![allow(unused)] fn main() { pub struct CommanderData { // Maps player indices to their commander indices pub player_commanders: std::collections::HashMap<usize, Vec<usize>>, // Maps commander indices to their current zone pub commander_zone_status: std::collections::HashMap<usize, CommanderZoneLocation>, // Tracks how many times a commander has moved zones pub zone_transition_count: std::collections::HashMap<usize, u32>, } }
bevy_persistent Integration
The save/load system uses bevy_persistent
for robust persistence. This implementation provides:
- Format Selection: Currently uses
Bincode
for efficient binary serialization. - Path Selection: Appropriate paths based on platform (native or web) and user configuration.
- Error Handling: Robust handling of failures during save/load operations with graceful fallbacks.
- Resource Management: Automatic resource persistence and loading.
Example integration from the setup_save_system
function with improved error handling:
#![allow(unused)] fn main() { // Create save directory if it doesn't exist let config = SaveConfig::default(); // Only try to create directory on native platforms #[cfg(not(target_arch = "wasm32"))] if let Err(e) = std::fs::create_dir_all(&config.save_directory) { error!("Failed to create save directory: {}", e); // Continue anyway - the directory might already exist } // Determine the appropriate base path for persistence based on platform let metadata_path = get_storage_path(&config, "metadata.bin"); // Initialize persistent save metadata with fallback options let save_metadata = match Persistent::builder() .name("save_metadata") .format(StorageFormat::Bincode) .path(metadata_path) .default(SaveMetadata::default()) .build() { Ok(metadata) => metadata, Err(e) => { error!("Failed to create persistent save metadata: {}", e); // Create a fallback in-memory resource Persistent::builder() .name("save_metadata") .format(StorageFormat::Bincode) .path(PathBuf::from("metadata.bin")) .default(SaveMetadata::default()) .build() .expect("Failed to create even basic metadata") } }; commands.insert_resource(config.clone()); commands.insert_resource(save_metadata); }
Configuration
The save system is configured through the SaveConfig
resource:
#![allow(unused)] fn main() { #[derive(Resource, Clone, Debug)] pub struct SaveConfig { pub save_directory: PathBuf, pub auto_save_enabled: bool, pub auto_save_frequency: usize, } }
This resource allows customizing:
- The directory where save files are stored
- Whether auto-saving is enabled
- How frequently auto-saves occur
Entity Mapping
One of the challenges in serializing Bevy's ECS is handling entity references. The save/load system solves this by:
- During Save: Converting entity references to indices using a mapping
- During Load: Recreating entities and building a reverse mapping
- After Load: Reconstructing relationships using the new entity handles
This approach ensures entity references remain valid across save/load cycles, even though the actual entity IDs change.
Replay System
The replay system extends save/load functionality by:
- Loading a saved game state
- Recording actions in a
ReplayAction
queue - Allowing step-by-step playback of recorded actions
- Providing controls to start, step through, and stop replays
Error Handling
The save/load system employs several error handling strategies:
- Corrupted Data: Graceful handling of corrupted saves with fallbacks to default values
- Missing Entities: Safe handling when mapped entities don't exist, including placeholder entities when needed
- Empty Player Lists: Special handling for saves with no players, preserving game state data
- Version Compatibility: Checking save version compatibility
- File System Errors: Robust handling of IO and persistence errors with appropriate error messages
- Directory Creation: Automatic creation of save directories with error handling and verification
- Save Verification: Verification that save files were actually created with appropriate delays
- Filesystem Synchronization: Added delays to ensure filesystem operations complete before verification
Example of handling corrupted entity mappings:
#![allow(unused)] fn main() { // If there's a corrupted mapping, fall back to basic properties if index_to_entity.is_empty() || index_to_entity.contains(&Entity::PLACEHOLDER) { // At minimum, restore basic properties not tied to player entities game_state.turn_number = save_data.game_state.turn_number; // For empty player list, set reasonable defaults for player-related fields if save_data.game_state.turn_order_indices.is_empty() { // Create a fallback turn order game_state.turn_order = VecDeque::new(); } } else { // Full restore with valid player entities **game_state = save_data.to_game_state(&index_to_entity); } }
Example of improved directory creation and save verification:
#![allow(unused)] fn main() { // Ensure save directory exists for native platforms #[cfg(not(target_arch = "wasm32"))] { if !config.save_directory.exists() { match std::fs::create_dir_all(&config.save_directory) { Ok(_) => info!("Created save directory: {:?}", config.save_directory), Err(e) => { error!("Failed to create save directory: {}", e); continue; // Skip this save attempt } } } } // ... saving process ... // Verify save file was created for native platforms #[cfg(not(target_arch = "wasm32"))] { // Wait a short time to ensure filesystem operations complete std::thread::sleep(std::time::Duration::from_millis(100)); if !save_path.exists() { error!("Save file was not created at: {:?}", save_path); continue; } else { info!("Verified save file exists at: {:?}", save_path); } } }
Testing
The save/load system includes comprehensive tests:
- Unit Tests: Testing individual components and functions
- Integration Tests: Testing full save/load cycles
- Edge Cases: Testing corrupted saves, empty data, etc.
- Platform-Specific Tests: Special considerations for WebAssembly
WebAssembly Support
For web builds, the save/load system:
- Uses browser local storage instead of the file system
- Handles storage limitations and permissions
- Uses appropriate path prefixes for the storage backend
See WebAssembly Local Storage for more details.
Performance Considerations
The save/load system is designed with performance in mind:
- Uses efficient binary serialization (Bincode)
- Avoids unnecessary re-serialization of unchanged data
- Performs heavy operations outside of critical game loops
- Uses compact data representations where possible
Future Improvements
Potential future enhancements:
- Incremental Saves: Only saving changes since the last save
- Save Compression: Optional compression for large save files
- Save Verification: Checksums or other validation of save integrity
- Multiple Save Formats: Support for JSON or other human-readable formats
- Cloud Integration: Syncing saves to cloud storage
Integration with Snapshot System
The save/load system is integrated with the snapshot system to enable visual differential testing of game states at different points in time or different steps in a replay.
Visual Differential Testing
Visual differential testing allows capturing renderings of a game state at specific points in a saved game or replay. These images can be compared to detect visual differences, regressions, or unexpected changes in the game's rendering.
Automatic Snapshots
The snapshot system automatically captures screenshots when:
- A game is saved (via
take_save_game_snapshot
system) - During replay, when steps are taken (via
take_replay_snapshot
system)
Manual Differential Testing
For more controlled testing, you can:
- Start a replay of a save file
- Press F10 at any point to capture the current state (via
capture_replay_at_point
system) - Continue stepping through the replay
- Press F10 again to capture another state for comparison
Programmatic Differential Testing
The capture_differential_game_snapshot
and compare_game_states
functions provide a programmatic way to perform visual differential testing:
#![allow(unused)] fn main() { // Compare turn 1 to turn 3 of a saved game let result = compare_game_states( &mut world, "my_save_game", (Some(1), None), // Turn 1 (Some(3), None), // Turn 3 ); match result { Some((reference_image, comparison_image, difference)) => { // Compare the images or save them for later comparison if difference > THRESHOLD { println!("Visual difference detected: {}%", difference * 100.0); } }, None => println!("Failed to capture comparison"), } }
SaveGameSnapshot Component
The integration uses the SaveGameSnapshot
component to link snapshots to specific saved games:
#![allow(unused)] fn main() { pub struct SaveGameSnapshot { /// The save slot name this snapshot is associated with pub slot_name: String, /// The turn number in the saved game pub turn_number: u32, /// Optional timestamp of when the snapshot was taken pub timestamp: Option<i64>, /// Optional description of the game state pub description: Option<String>, } }
Test Systems
The integration includes:
- Integration tests that verify snapshot events are triggered during save/load operations
- Functions to capture snapshots at specific points in a replay
- Comparison functions to detect visual differences between game states
Integration with Networking
Persistent Storage
WebAssembly Local Storage Support
The save/load system is designed to work seamlessly in both native desktop applications and WebAssembly environments running in a web browser.
How It Works
When your game runs in a web browser via WebAssembly, there is no traditional filesystem available. Instead, the save/load system automatically switches to using the browser's Local Storage API. This is done transparently, so your game code doesn't need to handle platform differences.
Path Handling
Path handling is abstracted to work across different platforms:
- Native Desktop: Saves are stored in the
saves/
directory relative to the game executable - WebAssembly: Saves are stored in the browser's local storage with keys prefixed with
saves/
This is implemented using bevy-persistent's storage backend system, where paths starting with /local/
are mapped to browser local storage.
Storage Limitations
When running in WebAssembly, be aware of these limitations:
-
Storage Size: Browsers typically limit local storage to 5-10MB total. This means all your saves combined should stay under this limit.
-
Persistence: Unlike files on a desktop computer, local storage can be cleared if:
- The user clears their browser data/cache
- The browser decides to free up space
- The user is in private/incognito mode (storage will be temporary)
-
Serialization Format: The system uses bincode serialization for efficiency, which works well for both platforms but produces binary data. In the browser, this binary data is base64-encoded to be stored in local storage.
Debugging WebAssembly Storage
To inspect saved game data in a browser:
- Open your browser's developer tools (F12 or right-click > Inspect)
- Go to the "Application" tab (Chrome) or "Storage" tab (Firefox)
- Look for "Local Storage" in the left panel
- Select your site's domain
- Look for entries with keys starting with
saves/
Implementation Details
The path handling is implemented through the get_storage_path
helper function, which conditionally compiles different paths based on the target platform:
#![allow(unused)] fn main() { fn get_storage_path(filename: &str) -> PathBuf { #[cfg(target_arch = "wasm32")] { // For WebAssembly, use local storage with a prefix Path::new("/local/saves").join(filename) } #[cfg(not(target_arch = "wasm32"))] { // For native platforms, use the filesystem Path::new("saves").join(filename) } } }
This ensures that save files are automatically directed to the appropriate storage system based on the platform.