Make a CCG – Enemy A.I.

Now that we have the freedom to attack, you might be thinking it would be nice if there were more options. In this lesson, we will add code so that the enemy opponent can actually play cards and attack us too. This will give a greater variety of move options, and add a bit of strategy to the game, all while giving a great boost to the fun factor. In my opinion, this is where it actually starts feeling like a real game!

Go ahead and open the project from where we left off, or download the completed project from last week here. I have also prepared an online repository here for those of you familiar with version control.

Card System

We have some nice structure in place for knowing what cards can attack or be targeted on the battlefield, but we don’t have anything similar in place for knowing which cards in a player’s hand are considered playable. In order to simplify the job of the A.I. to pick a card to play, let’s go ahead and have another system pre-determine all playable cards. As a bonus, this code would also then be re-usable in case you wanted to highlight the playable cards in the hand like we highlight minions that can attack. I’ll leave that last part as an exercise to the reader.

public List<Card> playable = new List<Card> ();

public void Refresh () {
	var match = container.GetMatch ();
	playable.Clear ();
	foreach (Card card in match.CurrentPlayer[Zones.Hand]) {
		var playAction = new PlayCardAction (card);
		if (playAction.Validate ())
			playable.Add (card);
	}
}

I added a new field called “playable” which is a “List” that holds “Card” instances. It will be updated by the system to hold cards that are able to be played from the player’s hand. We will update the list by calling the “Refresh” method. Here we clear the list so that we can start with a clean slate, and then simply re-add cards that are determined to be playable. We loop over each card in the current player’s hand, create a new “PlayCardAction” based on the card, and then validate the action. Assuming the action passes the validation check we can add the card to our list of playable cards. Note that the created action isn’t actually performed unless it is passed along to the action system, so we were able to use it here as an opportunity to reuse all of the validation logic that is already in place.

Enemy System

Now we have enough structure in place to begin implementing our rudimentary enemy A.I. We wont be creating anything challenging (unless you are just really bad at playing games in this genre) because the actions will be entirely performed at random. The goal is simply that it will actually take actions when it can, and therefore we will get a better sense of what a real game will feel like.

An A.I. that would be considered ready for a complete game is much more complex – there are a variety of algorithms you could try, from a simple list of rules that give weights to certain choices, to popular patterns like Minimax or Monte Carlo Tree Search. There are even options for Machine Learning. I am really interested in the Machine Learning route, but find it a very challenging topic – perhaps I will manage to wrap my head around it in the future.

public class EnemySystem : Aspect {

	public void TakeTurn () {
		if (PlayACard () || Attack ())
			return;
		container.GetAspect<MatchSystem> ().ChangeTurn ();
	}

	bool PlayACard () {
		var system = container.GetAspect<CardSystem> ();
		if (system.playable.Count == 0)
			return false;
		var card = system.playable.Random ();
		var action = new PlayCardAction (card);
		container.Perform (action);
		return true;
	}

	bool Attack () {
		var system = container.GetAspect<AttackSystem> ();
		if (system.validAttackers.Count == 0 || system.validTargets.Count == 0)
			return false;
		var attacker = system.validAttackers.Random ();
		var target = system.validTargets.Random ();
		var action = new AttackAction (attacker, target);
		container.Perform (action);
		return true;
	}
}

The system is pretty simple. It inherits from “Aspect” so that we can attach it to our main system container. There is only a single public method called “TakeTurn” which will be invoked during a “PlayerIdleState” as it enters, assuming of course that the current player is controlled by the computer. When the system is triggered, it will attempt to play a card. If no cards can be played, it will attempt to attack, and if no attack can be initiated, then it will change turns.

The “PlayACard” method grabs a reference to our “CardSystem”. It will know whether or not it can take an action here because of the count of playable cards held by this system. Assuming there are playable cards, it will pick one at random, create a “PlayCardAction” for the card, and pass it along to the ActionSystem to be performed. Note that we used an extension on the container rather than dealing with the ActionSystem directly.

The “Attack” method grabs a reference to our “AttackSystem”. It will know whether or not it can take an action here because of the count of the valid attackers and valid targets lists. Assuming there is at least one entry in each, we will pick randomly from each, and construct a new “AttackAction” and cause it to be performed.

Player Idle State

The “PlayerIdleState” currently calls a “Temp_AutoChangeTurnForAI” method so that our player can continue to play the game. Now that we have a system to handle A.I., we can remove this temporary method, and replace the statement that invokes it with the following:

container.GetAspect<CardSystem> ().Refresh ();
if (container.GetMatch().CurrentPlayer.mode == ControlModes.Computer)
	container.GetAspect<EnemySystem> ().TakeTurn ();

Just like we had used the “Enter” method to refresh the “AttackSystem” we will also need to refresh our “CardSystem” before we attempt to trigger the A.I. Next we check the “mode” of the current player to verify that the computer should be in control. If so, we can call the “TakeTurn” method on our “EnemySystem”.

Attack System

As I was putting together this lesson, a few things jumped out at me as not having been fully implemented. The first was the implementation of an attack. I had only applied damage from the attacker to the target. However, the target should have the opportunity to defend itself, and therefore apply some counter attack damage. I fixed that by refactoring the “OnPerformAttackAction” and adding the following:

void OnPerformAttackAction (object sender, object args) {
	var action = args as AttackAction;
	var attacker = action.attacker as ICombatant;
	attacker.remainingAttacks--;
	ApplyAttackDamage (action);
	ApplyCounterAttackDamage (action);
}

void ApplyAttackDamage (AttackAction action) {
	var attacker = action.attacker as ICombatant;
	var target = action.target as IDestructable;
	var damageAction = new DamageAction (target, attacker.attack);
	container.AddReaction (damageAction);
}

void ApplyCounterAttackDamage (AttackAction action) {
	var attacker = action.target as ICombatant;
	var target = action.attacker as IDestructable;
	if (attacker != null && target != null) {
		var damageAction = new DamageAction (target, attacker.attack);
		container.AddReaction (damageAction);
	}
}

Minion System

I also noted that my constraint on the max number of minions on the table had not been implemented. This was easily resolved by observing the “PlayCardAction” validation notification:

// Add to Awake
this.AddObserver (OnValidatePlayCard, Global.ValidateNotification<PlayCardAction> ());

// Add to Destroy
this.RemoveObserver (OnValidatePlayCard, Global.ValidateNotification<PlayCardAction> ());

// Notification Handler
void OnValidatePlayCard (object sender, object args) {
	var action = sender as PlayCardAction;
	var cardOwner = container.GetMatch ().players [action.card.ownerIndex];
	if (action.card is Minion && cardOwner[Zones.Battlefield].Count >= Player.maxBattlefield) {
		var validator = args as Validator;
		validator.Invalidate ();
	}
}

As a reminder, the notification observer for a validation notification should not list the “container” as the sender to observe, because the action itself serves as the sender. In the notification handler, we grab a reference to the player that owns the card being summoned (note that I don’t grab the “current player” because reactions / abilities in the future could cause the opponent to summon a card as well). Next we check to see if the card about to be played is a type of Minion, and if so that there is still room on the battlefield. If not, we will need to invalidate the action.

Death Action

After seeing my enemy opponent summon minions and giving me some new targets, I quickly noticed that I could attack them, but there was no real point. Reducing their hitpoints to zero or less did nothing to hinder their ability to attack. Even worse, if you filled your battlefield with low-cost cards, you would fill up your battlefield and not be able to summon high-cost cards. Clearly, we need a way to remove minions from the battlefield. Let’s kill them. We will add a new context object that marks cards for death like this:

public class DeathAction : GameAction {
	public Card card;

	public DeathAction (Card card) {
		this.card = card;
	}
}

Death System

Let’s add a new system to handle our new “Death Action”. I’ll be really clever and name it the “Death System”. Like usual it inherits from “Aspect” and it will also implement the “IObserve” interface.

public class DeathSystem : Aspect, IObserve {
	// Add code here...
}

We will be interested in two notifications. The first is posted by the ActionSystem after fully performing a “root” action, and will be used as an opportunity to look for any mortally wounded cards. The second is the “perform” notification of the death action itself.

public void Awake () {
	this.AddObserver (OnDeathReaperNotification, ActionSystem.deathReaperNotification);
	this.AddObserver (OnPerformDeath, Global.PerformNotification<DeathAction> (), container);
}

public void Destroy () {
	this.RemoveObserver (OnDeathReaperNotification, ActionSystem.deathReaperNotification);
	this.RemoveObserver (OnPerformDeath, Global.PerformNotification<DeathAction> (), container);
}

The handler for the death reaper notification will loop over all of the cards on the battle field for each player. Any that should be reaped will be reaped:

void OnDeathReaperNotification (object sender, object args) {
	var match = container.GetMatch ();
	foreach (Player player in match.players) {
		foreach (Card card in player[Zones.Battlefield]) {
			if (ShouldReap (card))
				TriggerReap (card);
		}
	}
}

The “ShouldReap” method looks at a card and attempts to cast it as a type of IDestructable. If a card is destructable, and also has its hitpoints reduced to zero or less, then it is determined that it should be reaped.

bool ShouldReap (Card card) {
	var target = card as IDestructable;
	return target != null && target.hitPoints <= 0;
}

The “TriggerReap” method simply creates a new “DeathAction” from a given card and adds it as a reaction to the current action.

void TriggerReap (Card card) {
	var action = new DeathAction (card);
	container.AddReaction (action);
}

The handler for the perform phase of the death action provides us an opportunity to actually implement the concept of death to a minion. All it really means is that the card’s zone should change to the “Graveyard”:

void OnPerformDeath (object sender, object args) {
	var action = args as DeathAction;
	var cardSystem = container.GetAspect<CardSystem> ();
	cardSystem.ChangeZone (action.card, Zones.Graveyard);
}

Game Factory

We’ve created two new systems, but need to make sure that they get instantiated and added to our system container. Add the following statements to the “Create” method:

game.AddAspect<DeathSystem> ();
game.AddAspect<EnemySystem> ();

Table View

One final step of polish and this lesson will be complete. Let’s provide a “viewer” for the death of a minion. We will use it as an opportunity to shrink the card to nothing, as well as to update the layout of all the other cards in the battlefield so that there are no gaps. We can use the OnEnable and OnDisable methods to attach and remove a new observer of the “prepare” phase of the DeathAction, and then use the notification handler to attach the viewer method to the “perform” phase. Note that we only add the “viewer” if the table view’s owning player matches the card’s owning player:

// Add to OnEnable
this.AddObserver (OnPrepareDeath, Global.PrepareNotification<DeathAction> ());

// Add to OnDisable
this.RemoveObserver (OnPrepareDeath, Global.PrepareNotification<DeathAction> ());

// Notification Handler
void OnPrepareDeath (object sender, object args) {
	var action = args as DeathAction;
	if (GetComponentInParent<PlayerView> ().player.index == action.card.ownerIndex)
		action.perform.viewer = ReapMinion;
}

The viewer method itself must grab the game object that currently represents the card that will be reaped. We can use the “GetMatch” method for this. Now we can use a tweener to scale the object to zero to animate it disappearing. I don’t yield as this plays, because I am happy for it to play alongside the next animation which will be when the other minions are being layed out. Next, we need to remove the MinionView component from the list of minions – we do this before calling “LayoutMinions” so that any gaps will be filled. Once the Layout animation has completed, I disable the cards view, reset its scale, and enqueue it in the pooler so it can be used again in the future.

public IEnumerator ReapMinion (IContainer game, GameAction action) {
	var reap = action as DeathAction;
	var view = GetMatch (reap.card);
	view.transform.ScaleTo (Vector3.zero);
	minions.Remove (view.GetComponent<MinionView> ());

	var tweener = LayoutMinions ();
	while (tweener != null)
		yield return null;

	view.SetActive (false);
	view.transform.localScale = Vector3.one;
	minionPooler.Enqueue(view.GetComponent<Poolable> ());
}

Demo

Play the game. The computer will now play cards that get summoned to the table. If it has an opportunity to attack, it will. Note that neither side should be able to summon more minions than are allowed by the Player’s const of “maxBattlefield”. Any minions that have their hit points reduced to zero will also be removed from the table. If you play strategically, you should always be able to win. There is still a small element of luck, due to the order each player draws playable cards, as well as by the potential of the computer to randomly make a good or bad move. The game is already starting to feel fun!

Summary

Originally I set out merely to allow the computer to play cards and attack. Along the way we added more complete implementations of some of our existing features, such as clamping the number of minions that can be summoned at a time, and making sure minions can counter attack. I also decided we should add a new feature by removing minions from the table when they die. All together, the game now feels like an actual game.

You can grab the project with this lesson fully implemented right here. If you are familiar with version control, you can also grab a copy of the project from my repository.

If you find value in my blog, you can support its continued development by becoming my patron. Visit my Patreon page here. Thanks!

Leave a Reply

Your email address will not be published. Required fields are marked *