9

This is a follow up/related question to a previous question I asked regarding Interface Segregation. I'll build up on the same example, providing a bit more context.

In the game I'm developing, a card is structured in the following manner:

  • A card must have a primary ability
  • A card may have an "ally" ability. If it does, it can be triggered, given a certain condition is satisfied. Actual condition is not relevant to the discussion.
  • A card may have a "sacrifice" ability. If it does, it can be sacrificed, removing it from play, to trigger this ability.

Note that a card may have none, one, or both, of the optional abilities, it is not limited to just one.

My initial design was to have one single Card interface, with the following methods:

public interface Card {

  /* other relevant methods snipped */
  
  void useAllyAbility(GameContext context);

  void useSacrificeAbility(GameContext context);
}

However, since not every card is going to have the corresponding abilities, I realised I would have to throw something along the lines of UnsupportedOperationException in the cases where a Card's useXxxAbility is called, but that card does not have the required ability. In practice, this should rarely happen, of course, since the game's UI client would not allow it, but it has to be implemented nevertheless.

My other approach was to separate the two methods in different interfaces, like so:

public interface Card {

  /* other required Card behavior */
}

public interface Sacrificable extends Card {

  void useSacrificeAbility(GameContext context);
}

public interface Allyable extends Card {

  void useAllyableAbility(GameContext context);
}

For reference, the Player interface that would end up invoking these cards would be:

public interface Player {

  void useSacrificeAbility(Sacrificable card, GameContext context);

  void useAllyAbility(Allyable card, GameContext context);
}

Where the Player implementation would ensure that the card is indeed playable (along with any other validations/restrictions). During initialization, depending on the card data, I would instantiate the correct class, and add it to a generic Collection<Card> (or domain-specific wrapping class). The UI Layer would then be responsible for casting the card into a more specific type based on user input.

Implementation-wise, however, I found this second approach to be much more complex than I initially thought. Some context here. Since it would be impractical to have a separate class for every single card in the game, I broke it up into the following hierarchy:

class AbstractCard implements Card {}

class SacrificableCard implements Sacrificable {

  public SacrificableCard(Card card, CardAbility sacrificeAbility) {}
}

class AllyableCard implements Allyable {

  public AllyableCard(Card card, CardAbiltiy allyAbility) {}
}

With a few more classes in between, which are irrelevant for the discussion at the moment (Like the linked question suggested, there are ItemCard and ChampionCard etc.). My main problem is how to implement a card that is both Sacrificable and Allyable. Would I need a new class, SacrificableAllyableCard? I figured this would get out of hand quickly if, somewhere down the line, I happen to need a third such interface, for example, Comboable. Then I would need additional classes for every single possible combination of interfaces.

I read about the Bridge pattern, which attempts to solve a similar problem, but I do not know how to apply it to this scenario, since I am adding new behaviour in each interface.

My only other thought was to use DynamicProxy, so that SacrificableCard and AllyableCard could wrap any type of Card, instead of the generic Card. But I fear this may complicate the code even more than just simply having both of these methods declared in the Card interface, which was the initial design.

My question(s), then, are the following:

  • Does "Interface Segregation" make sense in this case? Since the "client" of this interface expects to work with Cards, is there any benefit to separating the interfaces for Sacrificable/Allyable?
  • If everything is implemented in 1 interface, and I throw UnsupportedOperationException, does that violate LSP? My reasoning is that, even though both AllyableCard and SacrificableCard both implement the same interface, they would not be interchangeable with each other, since a SacrificableCard may not support useAllyAbility, which would result in a thrown exception.
  • Are dynamic proxies a "bigger" evil than simply declaring 1 wide interface?
  • What other approach would be recommended in this scenario?
5
  • What is CardAbiltiy? You might be on the right track there.
    – Bergi
    Aug 2, 2022 at 6:54
  • "the game's UI client would not allow it" - how would that work? How does the UI client know whether a card has an ability or not?
    – Bergi
    Aug 2, 2022 at 6:58
  • CardAbility is an interface/abstract class (haven't made the final decision yet) that represents an ability a card might have. For example, DrawCardsAbility, GainHealthAbility would be concrete implementations Aug 2, 2022 at 7:27
  • "How does the UI client know whether a card has an ability or not?" My initial idea was to separate the UI and the core gameplay layer. So, the gameplay layer would expose its state via events, for example record CardDrawnEvent(String name, CardAbility primaryAbiltiy, CardAbility sacrificeAbility, CardAbiltiy allyAbility). The client would use the data from these events to build up its own classes/state, and add whatever validations might be helpful, or convenient for a user. Aug 2, 2022 at 7:32

4 Answers 4

20

It is typically a bad idea to have “optional” methods in an interface that throw an exception when invoked. Yes, the Java standard library happens to do this, but this is widely considered to have been a mistake. Thus, your distinction between Card/Allyable/Sacrificable interfaces is much better. Now, consumers can inspect the card via instanceof to see which operations it supports.

But I think you are improperly requiring the creation of distinct classes for each combination of abilities. This is even an issue you raise yourself:

it would be impractical to have a separate class for every single card in the game

Let's return back to the initial description of your scenario:

  • A card must have a primary ability
  • A card may have an "ally" ability. If it does, it can be triggered, given a certain condition is satisfied. Actual condition is not relevant to the discussion.
  • A card may have a "sacrifice" ability. If it does, it can be sacrificed, removing it from play, to trigger this ability.

I'm not a fan of the style of object-oriented analysis where we look at nouns and verbs in a requirements document, but here it is actually helpful!

You say that there are cards and abilities. These are nouns, so candidates for classes. A card has a primary ability. A card has zero or one ally abilities and sacrifice abilities. You have not used any verbs that would suggest that the card has any methods/behaviours, aside from getters. At first approximation, this would suggest something like the following class structure:

class Ability {}

class Card {
  public Ability primaryAbility;
  public Optional<Ability> allyAbility;
  public Optional<Ability> sacrificialAbility;
}

Of course, the different ability types might be represented by different interfaces or classes if they differ in their behaviour.

Note that turning abilities from methods into independent objects and having a single class for cards gives us a lot of flexibility.

  • We can create new cards at run time, without having to create custom classes. In particular, we can specify cards in configuration files.

  • We can very easily inspect a card to see which abilities it supports. For example, a user interface might contain code like:

    for (Card c in cards) {
      addButton("play card", c.getPrimaryAbility());
    
      c.getAllyAbility()
       .ifPresent(ability -> addButton("assist allies", ability));
    
      c.getSacrificialAbility()
       .ifPresent(ability -> addButton("sacrifice card", ability));
    }
    

It is quite normal in games to have entities that have multiple behaviours that cannot be easily squeezed into a fixed class hierarchy, so the idea to represent abilities as objects is quite common. This tends to enable more complicated game mechanics. Related ideas and patterns:

  • Eric Lippert has a blog post series about “Wizards and Warriors” that starts with a typical class design for an RPG game (e.g. class Staff extends Weapon, class Wizard { public Staff weapon; }). However, it gets really hard to encode complex game mechanics into the code, for example dual-classing, or special interactions between items. Lippert eventually concludes (emphasis in original):

    The fundamental problem is my initial assumption that the business rules of the system are to be expressed by writing code inside methods associated with classes in the business domain — the wizards and daggers and vampires. We keep on talking about “rules”, and so apparently the business domain of this program includes something called a “rule”, and those rules interact with all the other objects in the business domain. So then should “rule” be a class? I don’t see why not! It is what the program is fundamentally about. It seems likely that there could be hundreds or thousands of these rules, and that they could change over time, so it seems reasonable to encode them as classes.

    Here, my proposed Ability class would be a bit like Lippert's Rule classes.

  • A related idea from Nystrom's book Game Programming Patterns is the type object pattern – turning “is-a” relationships between classes (inheritance) into “has” relationships (composition).

  • A couple of years back I wrote a related answer about representing game items that have overlapping but distinct capabilities. Similar to here, I suggested avoiding the creation of class hierarchies, and instead recommended creating adapter objects for when an object must be used through a particular interface. In your scenario, we might want to use a Card through its Sacrifice interface, which may be mediated by an appropriate Ability implementation. Core parts of the answer:

    Some concepts like conceptual hierarchies of real-world objects map very badly into object oriented models. Thoughts like “A gun is a weapon, and a sword is a weapon, therefore I'll have a Weapon base class from which Gun and Sword inherit” are misleading: real-word is-a relationships do not imply such a relationship in our model. […] A base class is not a place to factor out the common parts of multiple classes.

    It is not necessary to create a hierarchy. The two types Gun and Sword may be entirely unrelated. Whereas a Gun can fire() and reload() a Sword may only strike(). If you need to manage these objects polymorphically, you can use the Adapter Pattern to capture the relevant aspects. […]

    Instead of trying to figure out a class hierarchy for that mess, it is better to have a class that provides slots for various capabilities. These slots can be changed at runtime. Each slot would be a strategy/callback like OnDamageReceived or Attack. With your weapons, we may have MeleeAttack, RangedAttack, and Reload slots. These slots may be empty, in which case the object does not provide this capability. The slots are then called conditionally: if (item.attack != null) item.attack.perform().

8
  • Could you maybe provide an example of how to use "a Card through its Sacrificable" interface? Because, if I understood the last part of your answer, and if Card has an accessible property sacrificeAbility, then there is almost no need for useSacrificeAbility, since a caller could simply card.sacrificeAbility.ifPresent(ability -> ability.apply()) Aug 1, 2022 at 14:46
  • 2
    Having methods report UnsupportedOperationException without offering any way of querying for support is a mistake. On the other hand, there are many situations where it will be possible to use some means to perform some task on all objects that implement a certain interface, but for some implementations other means might be better. Further, especially with wrapper objects, there may be situations where some instances of a type will support a feature but others will not. Trying to encode everything in the type system will make such situations awkward.
    – supercat
    Aug 1, 2022 at 21:02
  • Perfect! I came here to post basically this, but you've already said everything I would've wanted to say, and quite eloquently as well. Aug 2, 2022 at 0:47
  • @StefanRendevski Correct, a useSomeAbility() method would be inappropriate, though perhaps there could be convenience methods like useSomeAbilityIfItExists() or hasSomeAbility(). Depending on the specific design, it is not necessary to store Abilities in fields. They could also be created on the fly in getter methods. Let's assume that Card is an interface, not a class. If we have a custom Card implementation, the returned Ability might just call back into the card implementation – an example of an adapter. But this is incompatible with the “slots” design.
    – amon
    Aug 2, 2022 at 8:22
  • @amon Currently, CardAbility is a functional interface, with an apply(GameContext) signature. GameContext contains a reference to the card that invoked the ability. So, yes, Ability instances can be created on the fly. The reason I chose to have useSomeAbility() method, was because of state. A certain ability can only be used once per turn. I placed this state management in Card, so that calling Ability.apply() directly would not update the state. Maybe I should reconsider, and either put state management in Ability, or have Ability call a method on Card to indicate change Aug 2, 2022 at 8:32
4

"However, since not every card is going to have the corresponding abilities, I realised I would have to throw something along the lines of UnsupportedOperationException"

You're thinking about it in the wrong way. As you outlined:

  • A card must have a primary ability
  • A card may have an "ally" ability. If it does, it can be triggered, given a certain condition is satisfied. Actual condition is not relevant to the discussion.
  • A card may have a "sacrifice" ability. If it does, it can be sacrificed, removing it from play, to trigger this ability.

These statements, in their essence, do not describe an interface; these are really the rules of the game.
When a card doesn't have the "ally" ability or the "sacrifice" ability, it's not an exceptional circumstance - so you shouldn't treat it as such (i.e., you shouldn't throw an exception, except maybe at construction time).

Given this perspective, you have different options here - you could retain the same interface, and implement these optional abilities as no-ops (Null Object Pattern, or something along those lines). Or, you could introduce the Ability object, and just have your interface be something that returns a list of abilities, or maybe a list of abilities by category (where you'd return an empty list when there are no abilities in that category). You'd probably have to modify your surrounding logic to work with this new interface, but doing one of these things avoids conditionals and null checks, and makes your code cleaner and more readable (as in, if you do it right, you'll find the code easy to make sense of).

That said, I'm not really trying to suggest any particular solution here, I just want to steer your thinking in a different direction. You know what the gameplay should be, so you'll probably come up with better ideas than I can produce. It's worth going down that path just to sort of "explore the design space", even if you decide to backtrack.

Finally, it's not necessarily the best idea to model different kinds of cards as different types (subclasses). If you have the Ability class, then Card could just be one class, and different kinds of cards would be represented by different combinations of Abilities. In such a scenario, the abilities might form a type hierarchy instead. That's quite a bit more flexible (you yourself have noticed that it would be impractical to have a separate class for each kind of card), and it's likely to work better with an editor or game UI. You could do other cool things, e.g., you could have prototypical card instances that you load from a file (perhaps created in an editor), and clone them when you need new instances of that type to form decks and whatnot.

12
  • A reason why I opted for throwing an exception, or really, treating a missing "sacrifice" ability as exceptional, is because, in the context of a method such as useSacrificeAbility, a caller should expect that the card was sacrificed and is no longer in play. Throwing an exception seemed a much cleaner approach than returning a boolean to indicate whether or not the ability was in fact used. Moreover, invoking a useSacrificeAbility where none exists suggests a bug somewhere in the code base, since certain validations have obviously been forgotten, because such an invocation mustn't happen Aug 1, 2022 at 14:36
  • @StefanRendevski What I'm saying is you wouldn't have useSacrificeAbility at all (for example). Think through your game logic, and solve these problems in the implementation of the gameplay itself, not in the interface. Maybe you don't need to know the concrete ability at compile time. Just show available abilities to the user, and react to their click. If the card doesn't have a sacrifice ability, it's not going to be there for them to click. What I'm saying is you probably need to reconceptualize this, instead of trying to manipulate the compiler to do these checks, or throwing. Aug 1, 2022 at 14:50
  • The game architecture is one of server-client, which will most likely be written in different languages altogether. A user would interact with the client, then the client would send the user input to the server for processing and updating of game state. The client would obviously check if an ability is present before presenting a button to the user, but a client is not always 100% reliable. A tech-savvy user could hijack the client to send faulty information to the server. The server then, would have to additionally validate user input, hence useSacrificeAbility. Aug 1, 2022 at 15:05
  • @StefanRendevski - This doesn't make much difference (with respect to what I'm saying) - it's just a different delivery mechanism for the input. If you need to validate the input, you should do it in the boundary of your application. The Card and Ability types would get the the already validated input from their callers, and shouldn't be responsible for that. But even if you don't validate the input, if you have a Card represented by a list of abilities, a tech savvy user cannot invoke an ability that's not present on the instance. Your card would just do nothing. Aug 1, 2022 at 16:10
  • 1
    One simple way to handle the Ability object is to make it an enum. Instead of useAllyAbility(context), it would be useAbility(Ability.ALLY, context), etc. That makes it easy to serialize, hides the implementation from the client, makes it relatively easy to add new abilities if you need them later, and presents a simple way to check for abilities (Set<Ability> getAbilities()). The big disadvantage is that the useAbility method needs to do its own dispatch. One way to do that is to maintain a private Map<Ability, AbilityHandler>, which powers both getAbilities and useAbility.
    – yshavit
    Aug 2, 2022 at 6:09
3

Does "Interface Segregation" make sense in this case?

Probably not. If I got this right, Sacrificable and Allyable are properties which may or may not be available by a card type which is best configured dynamically at runtime. Hence I would not use inheritance to model their availability, since inheritance is a compile time approach.

For practical purposes, there should be boolean attributes Sacrificable and Allyable in your class which provide the information if useSacrificeAbility and useAllyAbility can be called safely.

If everything is implemented in 1 interface, and I throw UnsupportedOperationException, does that violate LSP?

Not when the contract of those methods (which can be simply the informal documentation) says that a user has to expect this kind of exception. Moreover, it is not even clear to me if you really need a Card interface at all, or if Card might be a class with no further inheritance. If not, with no subtyping involved, there cannot be a LSP violation.

3
  • I am using composition over inheritance when it comes to SacrificableCard and AllyableCard, those classes being wrapper/decorator for an existing card. As for not needing interface/inheritance, is this with regards to the Sacrificable/Allyable interfaces, or Card in general? If it is the former, that is indeed my dilemma, whether or not those interfaces should exist. The only reason I added them was to avoid throwing UnsupportedOperationException in the case where a client tries to use an ability that does not exist. Aug 1, 2022 at 11:52
  • 1
    In my initial design, there were indeed methods like this one: Optional<CardAbiltiy> getSacrificeAbility, which indicated an absence of such an ability. Aug 1, 2022 at 11:54
  • @StefanRendevski: see my edit (but actually it does not matter: even with Card being an interface, there will be no LSP violation).
    – Doc Brown
    Aug 1, 2022 at 11:56
1

game's UI client would not allow it

How would the UI know that? It is a design smell for something external to know the capabilities of an object "better" than the object itself. More on this below.

The UI Layer would then be responsible for casting the card

Aside from doing casting, which in itself is a smell, the UI layer again seems to know something that the object should only know.

So let's say this is something the Card should know. What if instead of having methods for every capability only for the UI then to call if selected, there were only one method where the Card can present itself and it simply "add" the necessary options into the UI itself.

In this case the "UI" doesn't have to know what capabilities the card has, this responsibility can stay completely in the card object, where it (arguably) belongs. The UI will just stay the UI, only responsible for presenting options and then triggering the callback the Card specifies. It doesn't need to cast, nor know anything specific to a card so your problem would be solved.

2
  • 1
    The core game logic being encapsulated in Card, it would then expose its state to the game client. For the sake of discussion, the game client could receive an event, containing every property of the card, and there would be a UI component to instantiate an appropriate class, which class would then have certain validations. For the use case "trigger an ally ability", then, the game client would check if a card has an ally ability, or is of certain type, and not allow the action to continue. (contd) Aug 1, 2022 at 11:41
  • server wise, the UI layer would have a certain handler for the command "useAllyAbility", or "useSacrificeAbility". It will retrieve the requested objects, and perform type casting. The only "knowledge", then, that the UI layer has, is that it needs an "Allyable" to satisfy a request for "useAllyAbility", which seems logical to me Aug 1, 2022 at 11:43

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.