Building a Scalable Combo System in Unity – Need Help with Code Organization & Collider Rotation

Hi everyone,

My name is Azim, and I’ve been working on a combo system (currently a 2-attack combo) for my game. I’ve been struggling for quite some time with getting it to work smoothly and organizing my code in a maintainable way. I’m planning to add more combos in the future, and I’m worried that my current approach might become unmanageable over time.

Here’s an excerpt of my current code:



using UnityEngine;
using System.Collections;

public class AttackScript : MonoBehaviour
{
    [Header("Important References")]
    [SerializeField] private AttackComboData attackComboDataRef;
    [SerializeField] private PlayerAttackData currentWeaponData;
    [SerializeField] private GameObject rightAttackPosition;
    [SerializeField] private GameObject RotateSecondAttack;
    [SerializeField] private GameObject leftAttackPosition;
    [SerializeField] private GameObject upAttackPosition;
    [SerializeField] private GameObject downAttackPosition;
    [SerializeField] private LayerMask standardEnemy;
    [SerializeField] private Camera_System cameraSystemReference;

    private Coroutine ComboCoroutine = null;

    [Header("Debug")]
    public bool showHitboxes = false;

    [Header("Runtime Variables")]
    [HideInInspector] public float currentCooldown = 0f;
    [HideInInspector] public bool isCurrentlyAttacking = false;
    private GameObject activeAttackPosition;

    private int originalAttackDamage;
    private int runtimeAttackDamage;

    private void Awake()
    {
        originalAttackDamage = currentWeaponData.attackDamage;
        runtimeAttackDamage = originalAttackDamage;
    }

    private void Update()
    {
        if (currentCooldown > 0)
            currentCooldown -= Time.deltaTime;
        UpdateAttackDirection();
    }

    public void CheckoutForInputs()
    {
        if (Input.GetMouseButtonDown(0) && currentCooldown <= 0)
        {
            PerformAttack();
            isCurrentlyAttacking = true;
            currentCooldown = currentWeaponData.attackCooldown;
            cameraSystemReference?.TriggerShake();
        }
    }

    private void UpdateAttackDirection()
    {
        if (Input.GetKeyDown(KeyCode.W))
            activeAttackPosition = upAttackPosition;
        else if (Input.GetKeyDown(KeyCode.S))
            activeAttackPosition = downAttackPosition;
        else if (Input.GetKeyDown(KeyCode.A))
        {
            activeAttackPosition = leftAttackPosition;
            RotateSecondAttack.transform.position = new Vector2(transform.position.x, leftAttackPosition.transform.position.y);
        }
        else if (Input.GetKeyDown(KeyCode.D))
        {
            activeAttackPosition = rightAttackPosition;
            RotateSecondAttack.transform.position = new Vector2(transform.position.x, rightAttackPosition.transform.position.y);
        }
    }

    public void PerformAttack()
    {
        if (activeAttackPosition == null || currentWeaponData == null)
            return;

        Collider2D[] enemiesInRange = Physics2D.OverlapBoxAll(
            activeAttackPosition.transform.position,
            currentWeaponData.boxSize,
            0f,
            standardEnemy
        );

        foreach (var enemy in enemiesInRange)
        {
            if (enemy.TryGetComponent(out EnemyHealth enemyHealth))
            {
                enemyHealth.TakeDamage(runtimeAttackDamage);
                ApplyKnockBack(enemy);
            }
        }
    }

    private void ApplyKnockBack(Collider2D enemy)
    {
        if (enemy.TryGetComponent(out Rigidbody2D rb))
        {
            Vector2 direction = (enemy.transform.position - transform.position).normalized;
            rb.AddForce(direction * currentWeaponData.knockBackForce, ForceMode2D.Impulse);
        }
    }

    public void OnAttackAnimationEnd()
    {
        isCurrentlyAttacking = false;
    }

    private void OnDrawGizmos()
    {
        if (currentWeaponData == null)
            return;

        Gizmos.color = Color.red;
        if (rightAttackPosition != null)
            Gizmos.DrawWireCube(rightAttackPosition.transform.position, currentWeaponData.boxSize);
        if (leftAttackPosition != null)
            Gizmos.DrawWireCube(leftAttackPosition.transform.position, currentWeaponData.boxSize);
        if (upAttackPosition != null)
            Gizmos.DrawWireCube(upAttackPosition.transform.position, currentWeaponData.boxSize);
        if (downAttackPosition != null)
            Gizmos.DrawWireCube(downAttackPosition.transform.position, currentWeaponData.boxSize);
    }

    public void StartComboWindow()
    {
        if (ComboCoroutine == null)
        {
            ComboCoroutine = StartCoroutine(ReadSecondAttackInput());
        }
    }

    private IEnumerator ReadSecondAttackInput()
    {
        float timer = attackComboDataRef.inputWindow;

        while (timer > 0f)
        {
            if (Input.GetMouseButtonDown(0))
            {
                ComboCoroutine = null;
                attackComboDataRef.startSecondAttack = true;
                yield break;
            }
            timer -= Time.deltaTime;
            yield return null;
        }
        ComboCoroutine = null;
    }

    public void AddDamageToSecondAttack()
    {
        runtimeAttackDamage = attackComboDataRef.comboDamage;
        Debug.Log("Schaden für den zweiten Angriff gesetzt: " + runtimeAttackDamage);
    }

    public void ResetDamageToFirstAttack()
    {
        runtimeAttackDamage = originalAttackDamage;
        Debug.Log("Schaden für den ersten Angriff zurückgesetzt: " + runtimeAttackDamage);
    }

    private void OnDrawGizmosSelected()
    {
        if (currentWeaponData == null)
            return;

        if (showHitboxes && activeAttackPosition != null)
        {
            Gizmos.color = Color.green;
            Gizmos.DrawWireCube(activeAttackPosition.transform.position, currentWeaponData.boxSize);
        }

        if (showHitboxes && RotateSecondAttack != null)
        {
            Gizmos.color = Color.blue;
            Gizmos.DrawWireCube(RotateSecondAttack.transform.position, currentWeaponData.boxSize);
        }
    }
}

My Questions & Issues

  1. Code Organization & Extensibility:
    My current implementation is a monolithic script, and I’m finding it increasingly difficult to manage. I plan to add more combos in the future.
  • Do you separate the code into multiple classes for each combo or use a specific design pattern (like State or Command) to handle attacks?
  • What strategies or best practices do you recommend for structuring a combo system to ensure it remains maintainable and extensible?
  1. Collider Rotation Issue:
    I’m having an issue where the polygon collider for the second attack is always aligned to the right—even when the player is facing left.
  • Do you have a mechanism to rotate the collider based on the player’s facing direction?
  • What are the best practices to ensure that the collider adjusts dynamically when the player turns?
  1. General Best Practices:
  • What are some good habits or best practices you follow regarding Animation Events, combo systems, and collision checks?
  • How do you manage the interaction between animations and gameplay logic so that complex attacks don’t become overly intertwined and difficult to maintain?

Any advice, best practices, or experiences you could share would be greatly appreciated. I’ve been at this for a long time and keep hitting similar issues, and I’m eager to learn how to build a robust and scalable combo system.

Thanks in advance for your help!

Best regards,
Azim

Separation of concerns.

Separate the Input into Input Actions. If you were using the Input System package, that would already be handled for you as you’ll get callbacks like OnMove without embedding the actual key presses or even which kind of device executes the action.

Gizmos could also be in their own class or at least in a partial to separate it from the main code.

Applying damage should also be left to another system. Again, raise an event. OnAttackHit and perhaps the type of attack and the target.

This should not be a reference to the camera system (tight coupling) but rather raise an event that the camera system responds to (or not, if it feels like it, or some other system too).

Consider whether you need the transform most often. Looks like it. In that case make those fields Transform, not GameObject. You can still access the go the same way as you currently access the transform.

Avoid using Coroutines for timed tasks. This is just going to make things more complicated and harder to debug compared to simply doing a Time.time < comboButtonPressTimeOut conditional.

If you “time” anything do not accumulate or decrement delta time. Because this will accumulate floating point imprecision plus it’s unnecessary. Instead, mark the forward time where something has to end, and then simply check that:

        float timer = Time.time + attackComboDataRef.inputWindow;
        while (timer > Time.time)
        {
            yield return null;
        }

And again, this would work just the same in Update but without the necessity of the loop:

void Update()
{
        // assumption: attackComboTimer is a field and has been set elsewhere
        if (attackComboTimer > Time.time)
        {
            // check for additional button press here ...
        }
}

This frees you of the complexities of managing coroutines. It also looks way more elegant, doesn’t it? :wink:

It cannot be stressed enough how quickly and how ugly coroutines become when used for game logic, specifically when there’s more than one coroutines per script and you need to start managing when to start and stop them, plus you have no good way to see in the debugger which coroutines are currently running and which aren’t.

Animations: they come after the fact. This should be wholly event-driven such that you can play a different animation for every kind of event, plus any additional things like starting particle fx and playing audio.

In general, embrace events and be sure not to make them static because static anything tends to entangle everything. It’s also a bane for working with “disabled domain reload” which you should set in Project Settings so that you can enter playmode instantly.

By event I mean C# events, not Unity events:

public event Action<ComboType, Transform> OnAttackComboHit;

// in some method at the appropriate time
OnAttackComboHit?.Invoke(theComboType, theTargetTransformBeingHit);

Now any other script can get ahold of the reference to the combo script, and add or remove itself as an event listener:

theComboScriptRef.OnAttackComboHit += OnAttackComboHit;

private void OnAttackComboHit(ComboType theType, Transform target)
{
    // anim, fx, audio damage, etc could all be individual scripts responding 
    // to that event and each is doing their part of the job
}

First you should figure out the course of actions for what an attack constitutes. Something like:

  • button press
  • (optional) combo button press
  • player attack movement
  • collider impact
  • impact resolve
  • player attack resolution (may move player back)

Try to do this for every combo attack you imagine and find the “special” things that it needs to work. You’ll learn to understand which parts are the same for every kind of combo and that’ll make it easier to separate them and think about them. Only the “special” aspects of unique combos are likely going to need special code paths.

Perhaps the combo buttons themselves can be considered individual attacks and use the same system.

For instance four small punches finished by an upper punch tak-tak-tak-tak-FUMP that last one lifts the target upwards. But ultimately it is (button press, move attacker, hit target, move target, apply damage, play fx/anim) for all of these. Then you can start to see this is mostly about data.

In other words the attack data would be:

  • button event to respond to (ie basic attack vs kick attack)
  • velocity (or separately: speed + direction) to move attacker
  • velocity to move target
  • perhaps factor in “weight” for both which would alter the velocities
  • damage to apply
  • anim to play
  • fx to play

And that’s what you want. You want to concern yourself mostly with input and tweaking of the data whereas the attack system is simply a sequence of actions using the data with as little special code paths as possible.

1 Like

Hey, I really appreciate your detailed response! Your explanations have given me a much better understanding of how to structure my combo system in a way that makes it more modular, maintainable, and extensible.

Your point about separating concerns really stood out to me. I now see how relying on events instead of direct references can make my code much cleaner and more flexible. The idea of raising events for attack hits and camera shakes instead of tightly coupling them is something I hadn’t fully considered before, but it makes a lot of sense.

Also, your explanation about avoiding Coroutines for timed actions and instead using direct time comparisons was eye-opening. I hadn’t thought about floating-point imprecision accumulating over time, and your suggestion to use Time.time instead of decrementing a timer makes a lot of sense. I’ll definitely work on implementing this approach.

Regarding animations, I like the idea of making them event-driven so that different attack events can trigger different animations, effects, and sounds dynamically. That’s a much cleaner and more scalable way of handling animations than tying them directly into attack logic.

Your approach to breaking down attack sequences into clear steps (button press, movement, impact, resolution, etc.) really helped me see the bigger picture. Instead of writing special-case logic for each attack, I’ll try to abstract more of it into a general attack system where attacks are just different configurations of data.

Overall, your feedback has been incredibly valuable, and I really appreciate the time you took to explain everything in such detail. I’m excited to go back and refine my implementation based on your insights. Thanks again for your help!