Robust System for Unique/Customizable Card Effects

This questions is really just out of curiosity, since I know a simple method for creating cards that works for me.

Summary

What I want to know is: Is there a robust card system in which:

  • Cards have effects, which are any arbitrary behavior that can be coded.
  • I can define new effects (e.g. “deal X damage”, “draw Y cards”, “block Z damage”, any unique behavior).
  • I can compose new effects out other defined effects. For example,
    • I can combine the effects “deal damage” and “draw cards” to create a “deal damage and draw cards” effect
    • I can also define composed effects with other variables (e.g. an “echo” effect, composed of an effect and an integer n, will repeat the sub-effect n times)
  • I can compose effects entirely within the Unity Inspector.
    • Defining new effects will inevitably happen in the code, but I want the ability to create generic composed effects that can serialize their sub-effects and sub-effect variables entirely within the Unity Inspector.
    • That is, if I’ve already defined an “echo” effect and “deal X damage” effect, I can create a new Card with an “echo” effect composed of a “deal X damage” effect where X = 8, without ever having to look at the code or create extra assets.
  • I can compose effects dynamically during a game. The system supports the creation of new Cards with composed effects that the player is free to build out the predefined Cards I have created.
  • The implementation has minimal redundancy.
    • no passing data values that a particular Card or effect won’t use.
    • every unique Card is contained within a single asset. I don’t need to ever create a sub-component asset and then attach it to a card; I want all Unity Inspector action to occur when I create the new Card asset.

I haven’t been able to think up a system that has no redundancies at all, and so I’ve come looking for a programmer god to bestow knowledge upon me. If there are any CS topics to look into, data structures, design patterns, etc. I’m willing to do more research.

Details

I’m currently trying to develop a card game, so I’ve been looking at some implementations of other projects. I’ve noticed the following patterns:

  1. Scriptable Objects for card data
  2. Extending a base class with a new class for each different card

These card systems work for smaller card libraries, but I’m curious about very handling an extremely large number of cards. For example, see the following card design, which roughly follows the patterns I saw:

public class Card : ScriptableObject
{
    public string Description;
    public string CardName;
    public Sprite Art;

    public virtual void ApplyEffect(){}
}
public class AttackCard : Card 
{
    [SerializeField] private float _damage;

    public override void ApplyEffect(){ /*deal damage*/ }
} 
public class DrawCard : Card 
{
    [SerializeField] private int _numCards;

    public override void ApplyEffect(){ /*draw cards*/ }
} 

I want to create a card that can both draw and attack, while minimizing redundancies and allowing me to customize the card behavior in the Unity Editor. In the systems I found online, there is no real way to do this besides creating a class that is composed of AttackCard and DrawCard (or some form of inheritance + composition, ew). However, I don’t really know a system design that would allow this.

A couple of solutions I’ve looked into:

Drag out the ApplyEffect() function into its own class

Drag out the function ApplyEffect() into its own base class CardEffect. Class variables damage and numCards are moved into child classes of CardEffect. Now, there is only a Card class that contains a CardEffect, from where we can call CardEffect.ApplyEffect().

public class Card : ScriptableObject
{
    public string Description;
    public string CardName;
    public Sprite Art;

    public CardEffect Effect;
}
public class CardEffect : ScriptableObject
{
    public virtual void ApplyEffect(){}
}
public class AttackEffect : CardEffect
{
    [SerializeField] private float _baseDamage;

    public override void ApplyEffect(){/*do damage*/}
}
public class DrawEffect : CardEffect
{
    [SerializeField] private int _numCards;

    public override void ApplyEffect(){/*draw cards*/}
}

Now, I can define a StackedEffect that has a list of CardEffects that can be called:

public class StackedEffect : CardEffect
{
    public List<CardEffect> Effects;

    public override void ApplyEffect()
    {
        foreach(CardEffect e in Effects){
            e.ApplyEffect();
        }
    }
}

This solution works (and yes I could just define the CardEffect list in Card, but I wanted a recursive design where Card only ever sees “one effect”), but here are the pros and cons:
Pros

  • In the original design, I would have to create a subclass StackedCard which has a Card list. But each card has CardName, Description, etc., which doesn’t make sense for sub-behaviors of a combined card. This splits up the functions from the cards, so now you’re only passing what you need to the StackedEffect.

Cons

  • Not Unity Editor Friendly. Since Card doesn’t know which subclass is exactly attached to it, you can’t fill in variables like _numCards and _baseDamage.
  • Every time you want to add a CardEffect with a different internal value, you have to create a new ScriptableObject asset. For example, If I create a “Rock” that does 3 damage and a “Fireball” that does 6 damage, I would have to create two AttackEffect assets with different values, which is pretty redundant (I might as well just create unique scripts for each Card, and stick to inheritance though the Card class). The pain point is that CardEffect extends ScriptableObject, meaning you need to create separate instances whenever you want to change the variables of a CardEffect.

So CardEffect as a ScriptableObject doesn’t work great (author note: don’t listen to ChatGPT :skull:). My second solution was

Create an interface that implements ApplyEffect()

Create an ICardEffect interface that implements ApplyEffect(), then classes AttackEffect and DrawEffect can implement ICardEffect.

This implementation is nearly identical to the previous:

public interface ICardEffect
{
    public void ApplyEffect();
}
public class Card : ScriptableObject
{
    public string Description;
    public string CardName;
    public Sprite Art;

    public ICardEffect Effect;
}
public class AttackEffect : ICardEffect
{
    [SerializeField] private float _baseDamage;

    public void ApplyEffect(){/*do damage*/}
}
public class DrawEffect : ICardEffect
{
    [SerializeField] private int _numCards;

    public void ApplyEffect(){/*draw cards*/}
}
public class StackedEffect : ICardEffect
{
    public List<ICardEffect> Effects;

    public override void ApplyEffect()
    {
        foreach(ICardEffect e in Effects){
            e.ApplyEffect();
        }
    }
}

Pros

  • fixes the problem of the previous solution where you have to create a new asset every time you wanted to change the values

Cons

  • Still not Unity Editor friendly. Card doesn’t know the actual type of its Effect, so you can’t serialize the values in the editor, which is pretty inconvenient. I know that there are cusom Editor scripts that can help with this, but from what I’ve researched, they don’t play too nicely with interfaces and abstract classes.

I can circumvent this by adding an enum, which is my third solution:

Use an enum and a place every parameter in a struct to pass to CardEffect

CardEffect is a class once again (this could probably work as an interface too, since we’re not directly trying to call the construction of CardEffect with this solution):

public class CardEffect
{
    public virtual void ApplyEffect(){}
}

enum fixes the issue of CardEffect and ICardEffect not showing up in the Editor, since we can serialize the enum EffectType to determine which CardEffect subclass the Card object will have:

public enum EffectType{
    Attack,
    Draw,
    Stacked
}

[Serializable]
public struct Parameters{
    public int NumCards;
    public float BaseDamage;
    public List<EffectAndParameters> EffectsAndParameters; // just a little bit confusing
}

[Serializable]
public struct EffectAndParameters{
    public EffectType EffectType;
    public Parameters Parameters;
}

The Parameters struct stores the parameters for every subclass. It can be serialized in the Unity Editor.

Now, we can construct the subclass of CardEffect based on the enum. By creating an EffectAndParameters struct that packages Parameters and EffectType, we can define a CardEffect through a static function:

public static CardEffect GetCardEffect(EffectAndParameters effectAndParameters){
    EffectType type = effectAndParameters.EffectType;
    Parameters parameters = effectAndParameters.Parameters;
    switch(type){
        case EffectType.Attack:
            return new AttackEffect(parameters.BaseDamage);
        case EffectType.Draw:
            return new DrawEffect(parameters.NumCards);
        case EffectType.Stacked:
            return new StackedEffect(GetCardEffects(parameters.EffectsAndParameters));
    }
    return null;
}

Then we call GetCardEffect() in OnEnable() in Card:

public class Card : ScriptableObject
{
    [field:SerializeField] public string Description {get; private set;}
    [field:SerializeField] public string CardName {get; private set;}
    [field:SerializeField] public Sprite Art {get; private set;}

    private CardEffect _cardEffect;
    [field:SerializeField] public EffectAndParameters EffectAndParams {get; private set;}
    void OnEnable(){
        _cardEffect = CardEffect.GetCardEffect(EffectAndParams);
    }
}

Subclasses and take the variables they need from Parameters in their constructor. GetCardEffect() handles filtering out the parameters they need:

public class AttackEffect : CardEffect
{
    [SerializeField] private float _baseDamage;

    public AttackEffect(float baseDamage)
    {
        _baseDamage = baseDamage;
    }

    public override void ApplyEffect(){/*do damage*/}
}
public class DrawEffect : CardEffect
{
    [SerializeField] private int _numCards;

    public DrawEffect(int numCards)
    {
        _numCards = numCards;
    }

    public override void ApplyEffect(){/*draw cards*/}
}

StackedEffect takes in a List<CardEffect>. This can be retrieved from Parameters.EffectsAndParameters (this is why we packaged the two values together: for iteration) using another static function:

public class StackedEffect : CardEffect
{
    [SerializeField] private List<CardEffect> _effects;

    public StackedEffect(List<CardEffect> effects)
    {
        _effects = effects;
    }

    public override void ApplyEffect()
    {
        foreach(CardEffect e in _effects){
            e.ApplyEffect();
        }
    }
    
}
public static List<CardEffect> GetCardEffects(List<EffectAndParameters> effectsAndParameters){
    List<CardEffect> effects = new List<CardEffect>();
    foreach(EffectAndParameters effParams in effectsAndParameters){
        effects.Append(GetCardEffect(effParams));
    }
    return effects;
}

Pros

  • this fixes the issue of serialization within the Unity Editor since it works with structs. This also still has the same benefit as the Interface method, in that only one ScriptableObject is needed per card.
    Cons
  • A lot of unused data is passed into every Card. Seeing in the example above, every time GetCardEffect() is called it will receive a whole Parameters struct. It will always contain Parameters.baseDamage value, even if the intended CardEffect doesn’t deal damage.
  • Every time I define a new CardEffect that needs a new value I have to:
    • create a new enum constant that corresponds to the new class
    • add the new parameters to the Parameters struct
    • change the GetCardEffect() function to include the new enum constant

The GetCardEffect() code is now coupled together with each of the subclasses, and as the project grows, managing the enums can get out of hand.

So again my question is, is there a better solution that eliminates the redundancies of all options and creates a solution that works with the Unity Inspector GUI?

I think you need to look at [SerializeReference]: Unity - Scripting API: SerializeReference

And maybe invest in Odin Inspector.

I made something similar a while ago. I was using ScriptableObject and SerializeReference but it didn’t really work out very well. This was mainly from features I hadn’t planned on. So I might add an attack effect but then I’d need attack groups e.g. target all archers on the back row or own allies with less than x hp. Then I started needing conditions (10% chance of crit/miss), triggers (passive activates on fire damage) or timing (delay 1 second between each attack). Or running things in parallel or after effects complete e.g. heal self after all projectiles hit. Changing the structures required me to re-enter data. I also couldn’t easily reuse existing logic e.g. a special version of a card might do x2 damage but would require duplicating the data. It was also quite hard to read the data.

If I was doing it again I’d just stick with a behavior graph. So an effect just becomes a node. It can process timing / parallel / synchronization. You can share logic in sub graphs. You can easily modify a card by changing the blackboard data. The graph is just a scriptable object so as long as you don’t make new node classes you can deploy asset bundle updates for hotfixes. It’s also really easy to read and follow what the logic is doing in the graph. If I was making a multiplayer card game then I might make two graphs: one for the server and one for the client. Or if I wasn’t too worried about performance I might just run it all on the server.

Thanks for the tip!

I was looking more into it and found this really nice package for the inspector sets classes:

I’ll see how far I can get with this before playing with Odin Inspector or Behavior Graphs