Book Excerpt: AI For Game DevelopersBook Excerpt: AI For Game Developers

In an exclusive excerpt from O'Reilly's book AI for Game Developers, Bourg and Seeman run through some practical examples of rule-based AI, highlighting specific explanatory code relating to strike prediction for fighting games.

November 22, 2004

Author: by Glenn Seeman

Fighting Game Strike Prediction

In this example, we aim to predict a human opponent's next strike in a martial arts fighting game. The basic assumption is that the player will try to use combinations of strikes to find the most effective combination. These combinations can be something such as low kick, low kick, high kick; or punch, punch, power kick; and so on. We want the computer opponent to somehow learn to anticipate which strike the player will throw next given the most recently thrown strikes and some history of the player's strike patterns. If the computer can anticipate the next strike, it can throw an appropriate counter strike, or block, or take evasive action such as side-stepping or back-stepping. This will add a higher level of realism to the combat simulation and present new challenges for the player.

To achieve this, we're going to implement a rule-based system with a learning capability. We will achieve this learning by weighting each rule to reinforce some while suppressing others.

To keep this example manageable for discussion purposes, we're going to simplify things a bit. We'll assume that the player's strikes can be classified as punch, low kick, or high kick. And we're going to track three-strike combinations. Even with these simplifications we still end up with 27 rules to capture all possible three-strike combinations of punch, low kick, and high kick. We'll look at the rules in a moment, but first let's take a look at the structures and classes we need to implement the working memory and rules memory.

Working Memory

Example 1 shows how the working memory is implemented.

 enum TStrikes {Punch, LowKick, HighKick, Unknown};struct TWorkingMemory {  TStrikes   strikeA; // previous, previous strike (data)  TStrikes   strikeB; // previous strike (data)  TStrikes   strikeC; // next, predicted, strike (assertion)  // note: can add additional elements here for things such as   // which counter to throw, etc....};TWorkingMemory WorkingMemory; // global working memory variable Example 1. Working memory

TStrikes is just an enumerated type for the possible strikes. Note that we include Unknown for the case when the computer does not know what strike will be thrown.

TWorkingMemory is the structure defining the working memory. Here we have three elements: strikeA, strikeB, and strikeC. strikeC will store the predicted next strike to be thrown. This will be asserted by forward chaining through the rules given the observed facts, strikeA and strikeB. strikeB represents the most recently thrown strike while strikeA represents the strike thrown before strikeB. The three-strike combinations are strikeA, then strikeB, then strikeC, in that order, where strikeC is predicted by the rule system.

We can add more facts or assertions to the working memory if desired. For example, we can include a counter strike element that can be asserted given the predicted next strike. If the predicted next strike is, say, low kick, we can have rules that assert an appropriate counter such as back step, and so on. Given the way we're implementing the working memory and rules in this example, you easily can add new elements in the working memory as well as new rules.

Rules

Example 2 shows the rules class for this example. Note that we are not going to hardcode if-then rules. Instead, we'll keep an array of TRule objects to represent the rules memory. We easily could have used if-then constructs; however, the approach we're taking here makes it easier to add or delete rules and facilitates backward chaining, which we're going to use to a limited extent. We'll come back to this subject a little later.

 class TRule {public:TRule();void SetRule(TStrikes A, TStrikes B, TStrikes C);TStrikes     antecedentA;TStrikes     antecedentB;TStrikes     consequentC;bool         matched;int          weight;}; Example 2. Ruleclass

The TRule object contains five members. The first two are antecedentA and antecedentB. These members correspond to the previous two strikes thrown by the player. The next member, consequentC, corresponds to the predicted next strike--the strike that we'll assert using the rules. If we were using standard if-statements for the rules, we'd have rules that look something like this:

In an if-then style rule such as if X then Y, the “if X” part is the antecedent, or the premise. The “then Y” part is the consequent, or conclusion. In our example, we're assuming that our rules consist of the conjunction (logical AND) of two parameters: antecedentA and antecedentB. The then-part in our rules, consequentC, is the expected strike given the two previous strikes.

The next member in TRule is matched. This flag is set to true if the antecedents in the rule match the facts stored in working memory. More specifically, for a given rule, if antecedentA equals WorkingMemory.strikeA and antecedentB equals WorkingMemory.strikeB, the rule is matched. It's possible that more than one rule will match a given set of facts. This matched member helps us keep track of those that do match so that we can pick one to fire during the conflict resolution phase.

The final member in TRule is weight. This is a weighting factor that we can adjust to reinforce or inhibit rules. In a sense it represents the strength of each rule. Looking at it from a different angle, the weight represents the computer's belief that a given rule is more or less applicable relative to other potentially matching rules. During the conflict resolution phase where more than one rule matches, we'll fire the one rule with the highest weight to make a strike prediction. If after the next strike is thrown, we see that we fired the wrong rule - that is, we made a wrong prediction - we'll decrement the fired rule's weight to suppress it. Further, we'll figure out which rule should have been fired and increment its weight to reinforce it.

TRule contains only two methods, SetRule and the constructor. The constructor simply initializes matched to false and weight to 0. We use SetRule to set the other members - antecedentA, antecedentB, and consequentC - therefore defining a rule. SetRule is illustrated in Example 3.

 void TRule::SetRule(TStrikes A, TStrikes B, TStrikes C){  antecedentA = A;  antecedentB = B;  consequentC = C;} Example 3. SetRule method

We need a few global variables for this example. The first is WorkingMemory, as we showed in Example 4. Example 4 shows the others.

 TRule       Rules[NUM_RULES];int         vPreviousRuleFired;TStrikes    Prediction;TStrikes    RandomPrediction;int         N;int         NSuccess;int         NRandomSuccess; Example 4. Global variables

Here, Rules is an array of TRule objects. The size of the Rules array is set to NUM_RULES, which is defined as 27 for this example. PreviousRuleFired is an integer type that we'll use to store the index to the rule fired during the previous game cycle. Prediction keeps track of the strike prediction the rule system makes. Technically we don't need this because the prediction also is stored in working memory.

We're going to use RandomPrediction to store a randomly generated prediction to compare with our rule-based prediction. What we'll really compare is the success rate for our rule-based predictions versus the success rate for random guesses. The global variable N will store the number of predictions made. NSuccess will store the number of successful predictions made by our rule-based systems, while NRandomSuccess will store the number of successes for the random guesses. We calculate the success rates by dividing the number of successes by the total number of predictions.

Initialization

At the start of this simulation, or at the start of the game, we need to initialize all the rules and working memory. The Initialize function shown in Example 5 takes care of this for us.

 void TForm1::Initialize(void){  Rules[0].SetRule(Punch, Punch, Punch);  Rules[1].SetRule(Punch, Punch, LowKick);  Rules[2].SetRule(Punch, Punch, HighKick);  Rules[3].SetRule(Punch, LowKick, Punch);  Rules[4].SetRule(Punch, LowKick, LowKick);  Rules[5].SetRule(Punch, LowKick, HighKick);  Rules[6].SetRule(Punch, HighKick, Punch);  Rules[7].SetRule(Punch, HighKick, LowKick);  Rules[8].SetRule(Punch, HighKick, HighKick);  Rules[9].SetRule(LowKick, Punch, Punch);  Rules[10].SetRule(LowKick, Punch, LowKick);  Rules[11].SetRule(LowKick, Punch, HighKick);  Rules[12].SetRule(LowKick, LowKick, Punch);  Rules[13].SetRule(LowKick, LowKick, LowKick);  Rules[14].SetRule(LowKick, LowKick, HighKick);  Rules[15].SetRule(LowKick, HighKick, Punch);  Rules[16].SetRule(LowKick, HighKick, LowKick);  Rules[17].SetRule(LowKick, HighKick, HighKick);  Rules[18].SetRule(HighKick, Punch, Punch);  Rules[19].SetRule(HighKick, Punch, LowKick);  Rules[20].SetRule(HighKick, Punch, HighKick);  Rules[21].SetRule(HighKick, LowKick, Punch);  Rules[22].SetRule(HighKick, LowKick, LowKick);  Rules[23].SetRule(HighKick, LowKick, HighKick);  Rules[24].SetRule(HighKick, HighKick, Punch);  Rules[25].SetRule(HighKick, HighKick, LowKick);  Rules[26].SetRule(HighKick, HighKick, HighKick);  WorkingMemory.strikeA = sUnknown;  WorkingMemory.strikeB = sUnknown;  WorkingMemory.strikeC = sUnknown;  PreviousRuleFired = -1;  N = 0;  NSuccess = 0;  NRandomSuccess = 0;  UpdateForm();} Example 5. Initialize function

Here we have 27 rules corresponding to all possible three-strike combinations of punch, low kick, and high kick. For example, the first rule, Rules[0], can be read as follows:

Examining these rules, it's clear that more than one can match the facts stored in working memory at any given time. For example, if strikes A and B are punch, punch, respectively, the first three rules will match and the prediction could be punch, or low kick, or high kick. This is where the weight factor comes into play to help select which matching rule to fire. We simply select the rule with the highest weight. We pick the first rule encountered in the event that two or more rules have the same weight.

After all the rules are set, the working memory is initialized. Basically, everything in working memory is initialized to Unknown.

Strike Prediction

While the game is running we need to make a strike prediction after every strike the player throws. This will allow the computer opponent to anticipate the next strike the player will throw, as we've already discussed. In our example, we have one function, ProcessMove, to process each strike the player throws and to predict the next strike. Example 6 shows the ProcessMove function.

 TStrikes TForm1::ProcessMove(TStrikes move){    int   i;    int   RuleToFire = -1;  // Part 1:    if(WorkingMemory.strikeA == sUnknown)    {      WorkingMemory.strikeA = move;      return sUnknown;    }    if(WorkingMemory.strikeB == sUnknown)    {      WorkingMemory.strikeB = move;      return sUnknown;    }  // Part 2:  // Process previous prediction first    // Tally and adjust weights    N++;    if(move == Prediction)    {          NSuccess++;          if(PreviousRuleFired != -1)            Rules[PreviousRuleFired].weight++;    } else {              if(PreviousRuleFired != -1)                  Rules[PreviousRuleFired].weight--;                // Backward chain to increment the rule that                // should have been fired:                for(i=0; i Rules[RuleToFire].weight)          RuleToFire = i;      }    }    // Fire the rule    if(RuleToFire != -1) {        WorkingMemory.strikeC = Rules[RuleToFire].consequentC;        PreviousRuleFired = RuleToFire;    } else {        WorkingMemory.strikeC = sUnknown;        PreviousRuleFired = -1;    }    return WorkingMemory.strikeC;} Example 6. ProcessMove function

You can break this function into three distinctive parts, as indicated by the comments // Part 1, // Part 2, and // Part 3. Let's consider each part in turn.

Part 1

The first part populates the working memory. At the start of the game, after working memory is initialized and before any strikes are thrown, the working memory contains only Unknown values. This is insufficient to make a prediction, so we want to collect some data from the player as he begins to throw strikes. The first strike thrown is stored in WorkingMemory.strikeA and ProcessMoves simply returns Unknown without attempting a prediction. After the second strike is thrown, ProcessMoves is called again and this time the second strike is stored in WorkingMemory.strikeB. ProcessMoves returns Unknown one more time.

Part 2

The second part in ProcessMoves takes care of processing the previous prediction - that is, the prediction returned the previous time ProcessMoves was called. The first task in part 2 is to determine whether the previous prediction was accurate. ProcessMoves takes move as a parameter. move is the strike the player threw most recently. Therefore, if move equals the previous prediction stored in Prediction, we have a success. In this case, we increment NSuccess so that we can update our success rate. Then we reinforce the previously fired rule because it was the correct one to fire given the strike history stored in working memory. To reinforce a rule we simply increment the rule's weight.

If the previous prediction was wrong - that is, if move does not equal Prediction - we need to inhibit the previously fired rule. To do this we simply decrement the previously fired rule's weight. At the same time we want to reinforce the rule that should have been fired. To do this we have to figure out which rule should have been fired the last time ProcessMoves was called. To this end, we need to backward-chain a bit. Essentially, we know the move; therefore, we know what consequent should have been returned for the previous prediction. So, all we have to do is cycle through the last set of matched rules and pick the one who's consequentC equals move. Once we find the rule, we increment its weight and we're done.

The remaining tasks in part 2 of ProcessMoves are relatively simple. The next task is to see if the previous random prediction was correct and, if so, to increment the number of successful random predictions, NRandomSuccess.

Finally, we need to update the strikes in working memory in preparation for making a new prediction. To this end, we simply shift the strikes in working memory and add the most recent move. Specifically, WorkingMemory.strikeB becomes WorkingMemory.strikeA and move becomes WorkingMemory.strikeB. Now we're ready to make a new prediction for the new series of strikes stored in working memory.

Part 3

Referring to // Part 3 in Example 6, the first task in the prediction process is to find the rules that match the facts stored in working memory. We take care of this in the first for loop under the // Part 3 comment. Note that this is the so-called match phase of the forward chaining algorithm. Matching occurs when a rule's antecedentA and antecedentB equal WorkingMemory.strikeA and WorkingMemory.strikeB, respectively.

After the match phase, we need to pick one rule to fire from those that were matched during the matching phase. This is the conflict resolution phase. Basically, all we do is cycle through the matched rules and pick the one with the highest weight. We take care of this in the second for loop after the // Part 3 comment in Example 6. After this loop does its thing, the index to the selected rule is stored in RuleToFire. To actually fire the rule we simply copy consequentC of Rules[RuleToFire] to WorkingMemory.strikeC.

ProcessMoves stores the index to the fired rule, RuleToFire, in PreviousRuleFired, which will be used in part 2 the next time ProcessMoves is called. Finally, ProcessMoves returns the predicted strike.

That's pretty much all there is to this example. Upon running the example and simulating thrown strikes, by pressing buttons corresponding to punch, low kick, and high kick, we see that the rule-based system is pretty good at predicting the next strike. Our experiments saw success rates from 65% up to 80%. Comparing this to the roughly 30% success rate we achieved by guessing randomly, it's clear that such a rule-based system works very well.

Further Information

We've only scratched the surface of rule-based systems. Although we covered all the fundamental concepts and showed how effective rule-based systems are, other aspects to rule-based systems are worthwhile investigating if you plan to implement them for large-scale systems.

Optimization is one area that deserves attention. For small rule sets, forward chaining does not take much processing time; however, for larger sets of rules where many rules can match a given set of facts, it's wise to optimize the conflict resolution phase. The most common algorithm for this is the so-called Rete algorithm. (Check out the article “Rete: A Fast Algorithm for the Many Pattern/Many Object Pattern Match Problem,” by C. L. Forgy, Artificial Intelligence, 1982.) Most textbooks on rule-based, or expert, systems cover the Rete algorithm.

As you saw with our fighting example, you don't have to use if-then statements in a rule-based system. You don't even have to use the sort of enumerated types or other types, such as integers, Booleans, and so on. You can use strings to represent facts in working memory and string matching routines to determine if the antecedents of a rule (also, strings) match facts stored in working memory. This approach opens the door to scripting rules outside of the compiled program, which paves the way for designers to script AI rules. Indeed, developers have been using scripting languages, such as the well-known Prolog, Lisp, and CLIPS languages, for scripting rule-based systems for decades now. (There's even a relatively new Java-based language called JESS.) Another advantage to using a scripting language to implement rule-based systems is that it's easy to change, delete, or expand upon the rules without having to modify the compiled game code.

Instead of using a third-party scripting language, you can write your own; however, caution is in order here. Writing a scripted rule system to handle facts that can take on a range of values, along with rules with compound antecedents and consequents that might even trigger other events, is far more complicated than writing a rule system with only Boolean facts and simple rule structures. If you'd like to see how you might go about such a task, check out Chapter 8 of AI Application Programming by M. Tim Jones (Charles River Media). Note that the author's example is not a general-purpose scripting language such as Prolog and the others mentioned earlier, but it does show how to implement a simple rules-scripting algorithm from scratch.

As for other sources of information, the Internet is replete with web pages on rule-based systems and scripting shells. If you conduct an Internet search on rule-based systems, often abbreviated RBS, you're sure to find tons of links to pages that discuss rule-based systems in some context or another. Here are some Web sites that we find helpful for beginners:

http://www.aaai.org/AITopics/html/expert.html

http://ai-depot.com/Tutorial/RuleBased.html

--