Chat
Ask me anything
Ithy Logo
Sign in to save

Designing a Fully Asynchronous Unit Action System in C# for Turn-Based Strategy Games

Building Scalable and Maintainable Gameplay Mechanics with SOLID Principles

strategy game interface

Key Takeaways

  • Asynchronous Execution: Leveraging C#'s async/await patterns ensures non-blocking, efficient action processing.
  • SOLID Compliance: Adhering to SOLID principles promotes modularity, scalability, and ease of maintenance.
  • Comprehensive Class Structure: A well-defined class hierarchy facilitates the creation and management of diverse unit actions.

1. Introduction

In the realm of turn-based strategy games, the responsiveness and flexibility of unit actions significantly impact the player's experience. Designing a fully asynchronous unit action system in C# not only enhances performance but also ensures that the system remains scalable and maintainable as the complexity of the game grows. This document outlines the foundational classes and design principles necessary to develop such a system, emphasizing adherence to SOLID principles to achieve a robust and extensible architecture.


2. System Overview

The unit action system serves as the backbone for defining and executing the various actions that units can perform within the game. By structuring this system to operate asynchronously, we ensure that actions are processed efficiently without hindering the game's performance. The system consists of several core components, each responsible for a specific aspect of action management.

Core Components

  • ActionBase: An abstract class that provides the foundational properties and methods for all actions.
  • IAction Interface: Defines the contract for action execution, ensuring consistency across different action types.
  • ActionFactory: Responsible for creating instances of actions, promoting scalability and ease of extension.
  • ActionManager: Manages the registration, execution, and queuing of actions, orchestrating the overall action flow.
  • CooldownManager: Tracks and manages cooldown periods for units, ensuring balanced gameplay mechanics.
  • PowerCalculator: Handles the computation of effective action power, factoring in unit stats and target mitigations.

3. Bottom-Level Classes

The foundation of the asynchronous action system lies in its bottom-level classes. These classes encapsulate the core functionalities required for defining and executing unit actions while adhering to SOLID principles.

3.1. IAction Interface

The IAction interface defines the essential methods that every action must implement. This ensures a consistent contract across all action types, facilitating seamless integration and scalability.


public interface IAction
{
    string Name { get; } // Name of the action (e.g., "Attack", "Heal").
    int Range { get; } // Range at which the action can be performed.
    ActionType Type { get; } // Physical or Non-Physical.

    Task ExecuteAsync(Unit executor, Unit target); // Executes the action.
    Task<bool> CanExecuteAsync(Unit executor, Unit target); // Determines if the action is valid.
}
    

3.2. ActionBase Abstract Class

The abstract class ActionBase implements the IAction interface, providing shared behavior and properties for all concrete actions. This adheres to the Single Responsibility and Open/Closed principles by centralizing common functionalities.


public abstract class ActionBase : IAction
{
    public string Name { get; protected set; }
    public int Range { get; protected set; }
    public ActionType Type { get; protected set; }

    public async Task ExecuteAsync(Unit executor, Unit target)
    {
        if (!await CanExecuteAsync(executor, target))
        {
            throw new InvalidOperationException($"{Name} cannot be executed under current conditions.");
        }

        await PerformActionAsync(executor, target); // Template Method for specific action logic.
    }

    public abstract Task<bool> CanExecuteAsync(Unit executor, Unit target); // Rules for execution.
    protected abstract Task PerformActionAsync(Unit executor, Unit target); // To be implemented by subclasses.
}
    

3.3. Concrete Action Classes

3.3.1. PhysicalAction Class

Represents actions with physical effects, such as attacks. It overrides the power calculation to factor in the target's Defense stat.


public class PhysicalAction : ActionBase
{
    public PhysicalAction()
    {
        // Initialize properties specific to PhysicalAction
        Type = ActionType.Physical;
    }

    public override async Task<bool> CanExecuteAsync(Unit executor, Unit target)
    {
        // Implement rules specific to PhysicalAction
        return target != null && executor.Stats.Strength > 0;
    }

    protected override async Task PerformActionAsync(Unit executor, Unit target)
    {
        // Calculate effective power
        int effectivePower = executor.Stats.Strength - target.Stats.Defense;
        effectivePower = Math.Max(effectivePower, 0); // Ensure non-negative damage

        // Apply damage asynchronously
        target.Stats.Health -= effectivePower;
        await Task.CompletedTask; // Simulate asynchronous operation
    }
}
    

3.3.2. NonPhysicalAction Class

Represents actions with non-physical, mystical effects, such as healing. It utilizes the target's Affinity stat for power mitigation.


public class NonPhysicalAction : ActionBase
{
    public NonPhysicalAction()
    {
        // Initialize properties specific to NonPhysicalAction
        Type = ActionType.NonPhysical;
    }

    public override async Task<bool> CanExecuteAsync(Unit executor, Unit target)
    {
        // Implement rules specific to NonPhysicalAction
        return target != null && executor.Stats.Affinity > 0;
    }

    protected override async Task PerformActionAsync(Unit executor, Unit target)
    {
        // Calculate effective power
        int effectivePower = executor.Stats.Affinity - target.Stats.Affinity;
        effectivePower = Math.Max(effectivePower, 0); // Ensure non-negative effect

        // Apply healing asynchronously
        target.Stats.Health = Math.Min(target.MaxHealth, target.Stats.Health + effectivePower);
        await Task.Delay(100); // Simulate asynchronous delay
    }
}
    

3.3.3. MoveAction Class

Handles the movement of units across the game board. It calculates the movement range based on the unit's Speed stat.


public class MoveAction : ActionBase
{
    public MoveAction()
    {
        Name = "Move";
        Range = 0; // Movement range handled dynamically based on Speed
        Type = ActionType.Physical; // Assuming movement has physical implications
    }

    public override async Task<bool> CanExecuteAsync(Unit executor, Unit target)
    {
        // Implement movement-specific rules
        return target != null && executor.Stats.Speed > 0;
    }

    protected override async Task PerformActionAsync(Unit executor, Unit target)
    {
        // Calculate new position based on executor's Speed
        executor.Position = target.Position; // Simplistic movement logic
        await Task.CompletedTask; // Simulate asynchronous operation
    }
}
    

3.3.4. Specialized Actions (Attack, Heal, Pray)

These classes represent unique actions that may have additional properties or behaviors specific to their functionality.


public class AttackAction : PhysicalAction
{
    public AttackAction()
    {
        Name = "Attack";
        Range = 1;
    }

    // Additional behaviors or overrides can be implemented here
}

public class HealAction : NonPhysicalAction
{
    public HealAction()
    {
        Name = "Heal";
        Range = 2;
    }

    // Additional behaviors or overrides can be implemented here
}

public class PrayAction : ActionBase
{
    public bool IsPraying { get; private set; }

    public PrayAction()
    {
        Name = "Pray";
        Range = 0;
        Type = ActionType.NonPhysical;
    }

    public override async Task<bool> CanExecuteAsync(Unit executor, Unit target)
    {
        // Unit can toggle Pray state if not already praying
        return true;
    }

    protected override async Task PerformActionAsync(Unit executor, Unit target)
    {
        // Toggle the praying state
        IsPraying = !IsPraying;
        if (IsPraying)
        {
            // Apply praying effects, e.g., reduce stats
            executor.Stats.Strength /= 2;
            executor.Stats.Defense /= 2;
            // Activate God's Influence Aura if applicable
        }
        else
        {
            // Revert praying effects
            executor.Stats.Strength *= 2;
            executor.Stats.Defense *= 2;
            // Deactivate God's Influence Aura if applicable
        }
        await Task.CompletedTask; // Simulate asynchronous operation
    }
}
    

3.4. ActionFactory Class

The ActionFactory is responsible for instantiating action objects based on specified parameters. This promotes adherence to the Open/Closed principle by allowing new actions to be added without modifying existing factory logic.


public class ActionFactory
{
    public IAction CreateAction(string actionType, int power, int range, int cooldown)
    {
        switch(actionType.ToLower())
        {
            case "attack":
                return new AttackAction();
            case "heal":
                return new HealAction();
            case "move":
                return new MoveAction();
            case "pray":
                return new PrayAction();
            default:
                throw new ArgumentException("Invalid action type");
        }
    }
}
    

3.5. ActionManager Class

The ActionManager oversees the registration, execution, and management of actions. It maintains a queue to handle asynchronous execution, ensuring that actions are processed in an orderly and efficient manner.


public class ActionManager
{
    private readonly List<IAction> _availableActions = new List<IAction>();
    private readonly Queue<ActionRequest> _actionQueue = new Queue<ActionRequest>();

    public void RegisterAction(IAction action) => _availableActions.Add(action);

    public async Task ExecuteActionAsync(IAction action, Unit executor, Unit target)
    {
        if(action == null)
            throw new ArgumentNullException(nameof(action));

        await action.ExecuteAsync(executor, target);
    }

    public IEnumerable<IAction> GetAvailableActionsForUnit(Unit executor)
    {
        return _availableActions.Where(a => a.CanExecuteAsync(executor, null).Result);
    }

    public void EnqueueAction(IAction action, Unit executor, Unit target)
    {
        _actionQueue.Enqueue(new ActionRequest(action, executor, target));
    }

    public async Task ProcessQueueAsync()
    {
        while(_actionQueue.Count > 0)
        {
            var request = _actionQueue.Dequeue();
            await ExecuteActionAsync(request.Action, request.Executor, request.Target);
        }
    }
}

public class ActionRequest
{
    public IAction Action { get; }
    public Unit Executor { get; }
    public Unit Target { get; }

    public ActionRequest(IAction action, Unit executor, Unit target)
    {
        Action = action;
        Executor = executor;
        Target = target;
    }
}
    

3.6. CooldownManager Class

The CooldownManager tracks the cooldown periods of units, ensuring that actions adhere to their specified cooldowns. This prevents units from performing actions too frequently, maintaining game balance.


public class CooldownManager
{
    private readonly Dictionary<Unit, int> _cooldowns = new Dictionary<Unit, int>();

    public async Task ApplyCooldownAsync(Unit unit, int cooldown)
    {
        if(_cooldowns.ContainsKey(unit))
            _cooldowns[unit] = cooldown;
        else
            _cooldowns.Add(unit, cooldown);
        await Task.CompletedTask;
    }

    public async Task ReduceCooldownsAsync()
    {
        var keys = _cooldowns.Keys.ToList();
        foreach(var unit in keys)
        {
            if(_cooldowns[unit] > 0)
                _cooldowns[unit]--;
        }
        await Task.CompletedTask;
    }

    public bool IsOnCooldown(Unit unit)
    {
        return _cooldowns.ContainsKey(unit) && _cooldowns[unit] > 0;
    }
}
    

3.7. PowerCalculator Class

The PowerCalculator computes the effective power of actions by considering both the action's base power and the target's mitigating stats. This ensures that the impact of actions is dynamically adjusted based on unit attributes.


public class PowerCalculator
{
    public int CalculateEffectivePower(IAction action, Unit source, Unit target)
    {
        int basePower = action.Power;

        int mitigation = action.Type == ActionType.Physical ? target.Stats.Defense : target.Stats.Affinity;
        int effectivePower = basePower - mitigation;

        return Math.Max(effectivePower, 0); // Ensure non-negative power
    }
}
    

4. Action Execution Flow

Understanding the flow of action execution is crucial for designing an efficient and responsive system. The following outlines the sequence of steps involved in executing an action within the asynchronous unit action system.

4.1. Action Request

  • Initiation: A unit requests to perform an action against a target.
  • Validation: The ActionManager verifies if the action can be executed, checking cooldowns and target validity.
  • Queuing: Validated actions are added to the ActionManager's queue for asynchronous processing.

4.2. Queue Management

  • Enqueuing: Actions are enqueued in the order they are received.
  • Processing: The ActionManager processes the queue asynchronously, ensuring actions are executed sequentially without blocking the main thread.
  • Concurrency Handling: The system can manage multiple action requests concurrently, maintaining the integrity of the action sequence.

4.3. Action Resolution

  • Power Calculation: The PowerCalculator determines the effective power of the action based on source and target stats.
  • Effect Application: The action's effects are applied to the target asynchronously.
  • Conditional Logic: Actions can have branching logic, such as performing additional actions based on certain conditions.
  • Result Generation: Outcomes of actions are recorded, which can include success status and effects applied.

4.4. State Updates

  • Cooldown Application: After execution, the action's cooldown is applied to the unit.
  • Unit State Modification: Units involved in the action have their states updated accordingly (e.g., health changes, status effects).
  • Aura Management: Auras are checked and applied to units within range after actions are resolved.
  • Observer Notification: Observers or UI components are notified of state changes for real-time updates.

5. Power Calculation

Effective power calculation is pivotal in determining the impact of an action. It ensures that both the action's inherent strength and the target's defenses or resistances are appropriately factored in.

Parameter Description
Base Power The inherent strength of the action, defined by its Power attribute.
Mitigation The target's mitigating stat (Defense for Physical, Affinity for Non-Physical).
Effective Power Calculated as Base Power - Mitigation. Ensured to be non-negative.

Formula: $$\text{Effective Power} = \max(\text{Base Power} - \text{Mitigation}, 0)$$

The PowerCalculator class encapsulates this logic, ensuring consistency across all action types.


6. Cooldown Management

Cooldowns prevent units from performing actions too frequently, maintaining game balance. The CooldownManager class tracks and updates cooldown periods for each unit.

Method Purpose
ApplyCooldownAsync(Unit unit, int cooldown) Applies a cooldown period to a unit after an action is performed.
ReduceCooldownsAsync() Reduces the cooldown counters for all units, typically called at the end of each turn.
IsOnCooldown(Unit unit) Checks if a unit is currently on cooldown and unable to perform actions.

7. Asynchronous Considerations

Asynchronous programming is central to ensuring that the action system operates efficiently without blocking the game's main thread. Proper handling of asynchronous operations enhances performance, especially when dealing with complex or resource-intensive actions.

7.1. Utilizing async/await Patterns

  • Non-Blocking Execution: Actions execute asynchronously, allowing the game to remain responsive.
  • Task-Based Operations: Employing Task and Task<T> for asynchronous methods.
  • Error Handling: Implementing robust error handling within asynchronous methods to maintain system stability.

7.2. Managing Asynchronous Flow

  • Action Queue Processing: Ensuring that the action queue processes one action at a time to maintain order.
  • Concurrency Control: Preventing race conditions by managing access to shared resources.
  • Cancellation Tokens: Utilizing cancellation tokens to handle action cancellations gracefully.

Proper asynchronous design ensures that the action system scales with the complexity of the game, handling multiple actions efficiently while maintaining predictable behavior.


8. SOLID Principles in Action

Adhering to SOLID principles is essential for creating a maintainable and extensible action system. Each principle contributes to a well-structured architecture, facilitating future enhancements and reducing the likelihood of bugs.

8.1. Single Responsibility Principle (SRP)

  • Each class has one responsibility. For example, ActionManager handles action coordination, while CooldownManager manages cooldown logic.

8.2. Open/Closed Principle (OCP)

  • Classes are open for extension but closed for modification. New actions can be added by extending ActionBase without altering existing classes.

8.3. Liskov Substitution Principle (LSP)

  • Derived classes can substitute their base classes without altering the correctness of the program. For instance, AttackAction can replace ActionBase wherever required.

8.4. Interface Segregation Principle (ISP)

  • Interfaces are specific and concise. The IAction interface defines only essential action methods, preventing bloated interfaces.

8.5. Dependency Inversion Principle (DIP)

  • High-level modules depend on abstractions rather than concrete implementations. For example, ActionManager interacts with IAction interfaces instead of specific action classes.

9. Testing Strategy

Implementing a comprehensive testing strategy ensures the reliability and correctness of the action system. Testing should cover various aspects, including unit tests, integration tests, and performance assessments.

9.1. Unit Testing

  • Class Method Tests: Verify that each method behaves as expected under different scenarios.
  • Mocking Dependencies: Use mocking frameworks to test classes in isolation by simulating dependencies.

9.2. Integration Testing

  • Component Interaction: Test the interaction between classes, such as ActionManager and CooldownManager.
  • Action Flow: Ensure that actions are executed correctly from initiation to resolution.

9.3. Performance Testing

  • Load Testing: Assess how the system performs under high volumes of action requests.
  • Benchmarking: Measure execution times of critical methods to identify and optimize bottlenecks.

Regular testing not only catches bugs early but also provides confidence that new features do not disrupt existing functionalities.


10. Future Enhancements

While the foundational action system is robust, there are opportunities for future enhancements to enrich gameplay and system capabilities.

  • Advanced Action Types: Introduce complex actions with branching logic and multi-step processes, such as combos or conditional effects.
  • AI Integration: Develop intelligent decision-making algorithms for AI-controlled units, enabling dynamic and strategic action selection.
  • Multiplayer Support: Extend the action system to support multiplayer interactions, ensuring synchronized and fair action execution across different players.
  • Customizable Units: Allow for extensive customization of units, enabling players to create unique units with tailored action sets.
  • State Persistence: Implement state-saving mechanisms to preserve action histories and unit states across game sessions.

These enhancements will not only improve the depth and complexity of the game but also ensure that the system remains adaptable to evolving design requirements.


11. Conclusion

Designing a fully asynchronous unit action system in C# for turn-based strategy games involves meticulous planning and adherence to solid design principles. By leveraging asynchronous programming paradigms and adhering to SOLID principles, the system ensures efficiency, scalability, and maintainability. The outlined class structures and execution flows provide a robust foundation, while the emphasis on testing and future enhancements guarantees ongoing reliability and adaptability. Implementing this system will significantly enhance the gameplay experience, offering responsive and dynamic action management that scales with the game's complexity.


References


Last updated January 21, 2025

AI-assisted research. Sources, methodology and corrections · Source-checked guides and tools · Explore published answers

Ask Ithy AI
Download Article
Delete Article