Architecture Discussion: Writing a Card Game Rules Engine in Rust

I've started writing a card game in Rust (similar to something like Dominion or Magic), and I thought it would be interesting to get some suggestions from the beginning about how to implement the rules engine. A rules engine for a game can have a lot of different things to deal with, such as:

  • Hundreds of different effects that modify the game state
  • Dozens of different ways cards can trigger off of other changes to game state (like "when you are dealt damage by a flying creature, do X)
  • Complex query logic over the game state (e.g. "if you control more dinosaurs than your opponent, do X and Y")
  • Second-order effects ("if a creature would deal damage, it deals twice that much damage instead)
  • Persistent effects ("this creature can't attack if the opponent controls a dinosaur")

Obviously a pretty serious task!

I initially investigated an ECS-style approach to solving this, but ultimately I concluded that a complex turn-based game isn't as good of a fit for that pattern (the number of entities I need to deal with is usually <10 anyway). The direction I'm going in now is to extensively use Trait objects for everything, e.g.

struct Game {
  creatures: Vec<&dyn Creature>
}
trait Spell {
  fn on_cast(&mut Game);
}
trait Creature {
  fn on_played(&mut Game);
  fn on_attack(&mut Game);
  fn on_death(&mut Game);
  ...etc
}

I'm kind of worried that I'm falling back into my Object Oriented bad habits here, as I start to build out an elaborate trait hierarchy with different kinds of subtyping relationships. So I wanted to take a step back and ask about some more idiomatically "Rust" ways to architect a complex system like this. Are there some other options I should be thinking about? Thanks!

A quick one minute think:

You have big number of effects and a huge number of combinations. Enumerating them is hopeless (or pattern matching).

I would try to order all modifiers and create an struct Effect containing all information needed. Then each active modifier is applied in order on the effect.

struct Effect {
   source: Creature,
   action: Vec<Action>
}

each modifier would implement

    fn apply(&self, effect: &mut Effect, state: &GameState);
}

~

if effect.source.is_flying() {
    effect.actions.push(X);
}

~

if state.player.entities.iter().filter(|e| e.is_dinosaur()).count() >  state.opponent.entities.iter().filter(|e| e.is_dinosaur()).count() {
    effect.actions.push(X);
    effect.actions.push(Y);
}

~

for action in effect.actions.iter_mut() {
   if let Action::Damage(ref mut damage) = action {
       *damage *= 2.0;
   }
}

~

if state.opponent.entities.iter().any(|e| e.is_dinosaur()) {
    effect.actions.clear();
}
1 Like

I'd definitely agree @s3bk's approach of using what are effectively command objects, but wanted to mention a few things about how that impacts your interfaces.

That signature implies that the Spell just has free reign over the game state and can change it how it pleases, without interruption. That's not actually what you're going for within the rules of the game, you want to be able to intercept the spell's effect. So your signature will probably look more like:

trait Spell {
  fn on_cast(&Game) -> Effect // Or perhaps Vec<Effect>
}

But there's more problems if we're designing a MTG-esque game. (Which I'm picking as an example because I know it better than Dominion) If you wanted to model a MTG card like Giant Growth, which reads, "Target creature gains +3/+3 until end of turn", then the model is still missing two important aspects:

  1. It's paramaterized - the card does not specify the "target creature", we need to ask the user for that at some point.
  2. We need to queue something to happen on the end of the turn.

Now, my first idea for solving the second aspect was to pass a (set of) queue(s) to on_cast, so that the spell can add it's an undoing effect to a queue of things that happen at the end of the turn at the same time as it produces its Effect. But that doesn't actually match how the card works in MTG once the stack gets involved - if I play the card, but it fails to resolve, then obviously the +3/+3 effect doesn't happen, but neither does anything happen at the end of the turn. If we added an undo during on_cast, then that undo would still be executed even if the returned Effect was countered or nullified by later processing, which in turn means the creature I tried and failed to buff would lose 3/3 from its original values.

The solution I came up with is for the Effects from the direct execution of cards to also carry auxillary Effects that get queued to happen later in some sort of game-wide state, along with some type hacking so that we can be sure we don't try to execute effects that haven't had their targets chosen. Here's an example, on Playground for readability.

Obviously your game mechanics might not include exactly these aspects of MTG, paritcularly the opponent's ability to interrupt you, and so you don't need to handle the possibility of the effect that's returned from on_cast not actually happening as expected, but I hope that example helps to illustrate why indirection has uses in this case.

As well as global hooks that fire when anything attacks, enters the game, etc, you probably also want cards to have methods for both deciding what to do when things happen to them and also whether they can be acted on or do acting, as well as for exactly what they're doing to other entities. For instance, in the Creature interface you gave, as well as the things you already listed, you'd likely want methods like,

fn on_attacked(&Game) -> Option<Effect>;
fn can_attack(&Game) -> bool; // { !game.getOpponent().creatures.any(Creature::is_dinosaur) }
fn can_be_blocked(&Game, victim: &Creature) -> bool; // "Flying" is impled by saying {!victim.has_trait(Trait:Flying)} here
fn can_be_targeted_by(&Game, &Effect) -> bool; // "Protection from..."
//etc

Most of these are going to be empty in most cases and you can implement them as stubs that always return true/None/etc, but they have to exist if you need them in even one case since the language is statically typed and has no RTTI. (Unless you want to go the duck typing route, but at that point you might as well be writing Python IMO)

...That turned out to be a lot longer than I originally intended, but hope it gave you some ideas about how you can model the effects you're after in your game.

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.