Chapter 3: Randomness and Probability

In this chapter, we will look at how we can apply the concepts of probability and randomness to game AI. Because we will talk more about the use of randomness in game AI and less about Unity3D, we can apply the concepts of this chapter to any game development middleware or technology framework. We'll be using Mono C# in Unity3D for the demos, but we won't address much on the specific features of the Unity3D engine and the editor itself.

Game developers use probability to add a little uncertainty to the behaviors of AI characters and the wider game world. Randomness makes artificial intelligence look more realistic and natural, and it is the perfect "spice" for all those cases in which we do not need intentional predictability.

In this chapter, we will look at the following topics:

  • Introducing randomness in Unity
  • Learning the basics of probability
  • Exploring more examples of probability in games
  • Creating a slot machine

Technical requirements

For this chapter, you just need Unity3D 2022. You can find the example project described in this chapter in the Chapter 3 folder in the book repository: https://github.com/PacktPublishing/Unity-Artificial-Intelligence-Programming-Fifth-Edition/tree/main/Chapter03.

Introducing randomness in Unity

Game designers and developers use randomness in game AI to make a game and its characters more realistic by altering the outcomes of characters' decisions.

Let's take an example of a typical soccer game. One of the rules of a soccer game is to award a direct free kick to a team if one opposing team player commits a foul while trying to retake control of the ball. However, instead of giving a free kick whenever that foul happens, the game developer can apply a probability to reward only 98% of all the fouls with a direct free kick.

After all, in reality, referees make mistakes sometimes. As a result of this simple change, the player usually gets a direct free kick as expected. Still, when that remaining two percent happens, the game provides more emotional feedback to both teams (assuming that you are playing against another human, one player will be happy while the other will complain with the virtual referee).

Of course, randomness is not always a desirable perk of AI. As we anticipated in the introduction, some level of predictability allows players to learn the AI patterns, and understanding the AI patterns is often the main component of gameplay. For example, in a stealth game, learning the enemy guards' paths is necessary to allow the player to find a sneaking route. Or imagine you need to design a boss for a game such as Dark Souls.

Learning the big boss attack patterns is the player's primary weapon and the only proper way to achieve mastery for boss fights. As always, you have to follow the polar star of game design: do only what it is fun for the player. If adding randomness adds only frustration for the players, then you should remove it without exceptions.

However, in some cases, a bit of randomness is useful, and for some games, such as gambling minigames, it is a necessary prerequisite. In those cases, how can a computer produce random values? And more importantly, how can we use random numbers in Unity?

Randomness in computer science

Computers are deterministic machines: by design, if we give a computer the same input multiple times, in the form of program code and data, it always returns the same output. Therefore, how can we have a program return unpredictable and random output?

If we need genuinely random numbers, then we need to take this randomness from somewhere else. That's why many advanced applications try to combine different external sources of randomness into a random value: they may look at the movement of the mouse during a specific interval, to the noise of the internet connection, or even ask the user to smash the keyboard randomly, and so on. There is even dedicated hardware for random number generation!

Fortunately, in games, we do not need such genuinely random numbers, and we can use simpler algorithms that can generate sequences that look like a sequence of random numbers. Such algorithms are called Pseudorandom Number Generators (PRNGs). Using an initial seed, they can generate, in a deterministic way, a sequence of numbers that statistically approximate the properties of a sequence of truly random numbers. The catch is that if we start from the same seed, we always get the same sequence of numbers.

For this reason, we usually initialize the seed value from something that we imagine is always different every time the user opens the application, such as, for instance, the elapsed time in milliseconds since the computer started running, or the number of milliseconds since 1970 (the Unix timestamp). Note, however, that having the possibility to obtain the same random sequence every time is truly beneficial when debugging!

Finally, note that some PRNGs are more random than others. If we were creating an encryption program, we would want to look into less predictable PRNGs, called Cryptographically Secure Pseudorandom Number Generators (CSPRNGs). Fortunately, for games, the simple Random Number Generation (RNG) that comes with Unity is good enough.

The Unity Random class

The Unity3D script has a Random class to generate random data. You can set the generator seed using the InitState(int seed) function. Usually, we wouldn't want to repeatedly seed the same value, as this generates the same predictable sequence of random numbers at each execution.

However, there are some cases in which we want to give the user control over the seed – for instance, when we test the game or want the players to generate a procedural map/level with a specific seed. Then, you can read the Random.value property to get a random number between 0.0 and 1.0. This generator is inclusive, and therefore, this property can return both 0.0 and 1.0.

For example, in the following snippet, we generate a random color by choosing a random value between 0 and 1 for the red, green, and blue components:

Color randomColor = new Color(Random.value, Random.value, Random.value);

Another class method that can be quite handy is the Range method:

static function Range (min : float, max : float) : float

We can use the Range method to generate a random number from a range. When given an integer value, it returns a random integer number between min (inclusive) and max (exclusive). Therefore, if we set min to 1 and max to 4, we can get 1, 2, or 3, but never 4. Instead, if we use the Range function for float values, both min and max are inclusive, meaning we can get 1.0, or 4.0, or all the floats in between. Take note whenever a parameter is exclusive or inclusive because it is a common source of bugs (and confusion) when using the Unity Random class.

A simple random dice game

Let's set up a straightforward dice game in a new scene where we need to guess the output of a six-sided dice (simulated by generating a random integer between one and six). The player wins if the input value matches the dice result generated randomly, as shown in the following DiceGame.cs file:

using UnityEngine;

using TMPro;

using UnityEngine.UI;

public class DiceGame : MonoBehaviour {

    public string inputValue = "1";

    public TMP_Text outputText;

    public TMP_InputField inputField;

    public Button button;

    int throwDice() {

        Debug.Log("Throwing dice...");

        Debug.Log("Finding random between 1 to 6...");

        int diceResult = Random.Range(1,7);

        Debug.Log($"Result: {diceResult}");

        return diceResult;

    }

    public void processGame() {

        inputValue = inputField.text;

        try {

            int inputInteger = int.Parse(inputValue);

            int totalSix = 0;

            for (var i = 0; i < 10; i++) {

                var diceResult = throwDice();

                if (diceResult == 6) { totalSix++; }

                if (diceResult == inputInteger) {

                    outputText.text = $"DICE RESULT:

                      {diceResult} YOU WIN!";

                } else {

                    outputText.text = $"DICE RESULT:

                      {diceResult} YOU LOSE!";

                }

            }

            Debug.Log($"Total of six: {totalSix}");

        } catch {

            outputText.text = "Input is not a number!";

            Debug.LogError("Input is not a number!");

        }

    }

}

In the previous code, we saw the DiceGame class that implements the whole game. However, we still need to set up the scene with the appropriate UI object to accept the player's inputs and display the results:

  1. First, we need to create guiText to show the result. Click on Game Object | UI | Text - TextMeshPro. This will add New Text text to the game scene.
  2. Center it at the top of the canvas.
  3. Then, in the same way, create a button by selecting Game Object | UI | Button – TextMeshPro and an input field by selecting Game Object | UI | Input Field – TextMeshPro.
  4. Arrange them vertically on the screen.
  5. Create an empty game object and call it DiceGame. At this point, you should have something similar to Figure 3.1:
Figure 3.1 – Our simple Unity interface

Figure 3.1 – Our simple Unity interface

  1. Select the text inside the button and replace Button with Play! in the TextMeshPro component.
  2. Select the New Text text and replace it with Result: in the TextMeshPro component:
Figure 3.2 – The TextMeshPro component

Figure 3.2 – The TextMeshPro component

  1. Now, attach the DiceGame component to the DiceGame object, and connect into the DiceGame component the tree UI elements that we created before:
Figure 3.3 – The DiceGame component

Figure 3.3 – The DiceGame component

  1. Finally, select Button and look for the onClick() section in the Button component. Drag and drop the DiceGame object into the field with None (GameObject) and select DiceGame | processGame () from the drop-down menu. This will connect the processGame function to the click event for the button:
Figure 3.4 – The On Click event configuration

Figure 3.4 – The On Click event configuration

At this point, the game should be ready. Click Unity's play button and give it a go.

To successfully manage random numbers, we need to have a basic understanding of the laws of probability. So, that's what we are going to learn in the next section.

Learning the basics of probability

There are many ways to define probability. The most intuitive definition of probability is called frequentism. According to frequentism, the probability of an event is the frequency with which the event occurs when we repeat the observation an infinite amount of times. In other words, if we throw a die 100 times, we expect to see a six, on average, 1/6th of the times, and we should get closer and closer to 1/6th with 1,000, 10,000, and 1 million throws.

We can write the probability of event A occurring as P(A). To calculate P(A), we need to know all the possible outcomes (N) for the observation and the total number of times in which the desired event occurs (n).

We can calculate the probability of event A as follows:

If P(A) is the probability of event A happening, then the probability of event A not happening is equal to the following:

The probability must be a real number between zero and one. Having a probability of zero means that there's no chance of the desired event happening; on the other hand, having a probability of one means that the event will occur for sure. As a consequence, the following must equal to one:

However, not all events are alike. One of the most critical concepts in probability calculus is the concept of independent and non-independent events. That's the topic of the next section.

Independent and correlated events

Another important concept in probability is whether the chance of a particular event occurring depends on any other event somehow. For example, consider throwing a six-sided die twice and getting a double six. Each die throw can be viewed as an independent event. Each time you throw a die, the probability of each side turning up is one in six, and the outcome of the second die roll does not change depending on the result of the first roll. On the other hand, in drawing two aces from the same deck, each draw is not independent of the others. If you drew an ace in the first event, the probability of getting another ace the second time is different because there is now one less ace in the deck (and one less card in the deck).

The independence of events is crucial because it significantly simplifies some calculations. For instance, imagine that we want to know the probability of either event A or event B happening. If A and B are two independent events, then we can add the probabilities of A and B:

In the same way, if we want to know the probability that both A and B occur, then we can multiply the individual probabilities together:

For instance, if we want to know the probability of getting two sixes by throwing two dice, we can multiply 1/6 by 1/6 to get the correct probability: 1/36.

Conditional probability

Now, let's consider another example. We are still throwing two dice, but this time, we are interested in the probability that the sum of the numbers showing up on two dice is equal to two. Since there's only one way to get this sum, one plus one, the probability is the same as getting the same number on both dice. In that case, it would still be 1/36.

But how about getting the sum of the numbers that show up on the two dice to seven? As you can see, there are a total of six possible ways of getting a total of seven, outlined in the following table:

Figure 3.5 – The possible outcomes of two dice

Figure 3.5 – The possible outcomes of two dice

In this case, we need to use the general probability formula. From the preceding table, we can see that we have six outcomes that give us a total sum of seven. Because we know that there are 36 total possible outcomes for 2 dice, we can quickly compute the final probability as 6/36 or, simplifying, one-sixth (16.7%).

Loaded dice

Now, let's assume that we haven't been all too honest, and our dice are loaded so that the side of the number six has a double chance of landing facing upward. Since we doubled the chance of getting six, we need to double the probability of getting six – let's say, up to roughly one-third (0.34) – and as a consequence, the rest is equally spread over the remaining five sides (0.132 each).

We can implement a loaded dice algorithm this way: first, we generate a random value between 1 and 100. Then, we check whether the random value falls between 1 and 35. If so, our algorithm returns six; otherwise, we get a random dice value between one and five (since these values have the same probability).

For this, we create a new class called DiceGameLoaded. The game is identical to DiceGame but with an important difference: the throwDice function is changed, as follows:

    int throwDice() {

        Debug.Log("Throwing dice...");

        int randomProbability = Random.Range(0, 100);

        int diceResult = 0;

        if (randomProbability < 35) {

            diceResult = 6;

        } else {

            diceResult = Random.Range(1, 5);

        }

        Debug.Log("Result: " + diceResult);

        return diceResult;

    }

To try this new version of the game, swap the DiceGame component with the DiceGame component in the DiceGame object and rebind the onClick button event as we did before. If we test our new loaded dice algorithm by throwing the dice multiple times, you'll notice that the 6 value yields more than usual.

As you can see, the code is very similar to the non-loaded dice. However, this time, we are throwing an unfair dice that returns six much more than it should: we first select a random number between 0 and 100; if the number is less than 35, we return 6. Otherwise, we choose a random number between 1 and 5. Therefore, we get a 6 35% of the time and every other number roughly 15% of the time (we divide the remaining 75% by 5).

Remember that, in games, it's not cheating if the goal is to give the player a more exciting and fun experience!

Exploring more examples of probability in games

In this section, we will explore some of the most common applications of probability and randomness in video games.

Character personalities

Probability and randomness are not only about dice. We can also use a probability distribution to specify an in-game character's specialties. For example, let's pretend we designed a game proposal for a population management game for the local government. We need to address and simulate issues such as taxation versus global talent attraction, and immigration versus social cohesion. We have three types of characters in our proposal – namely, workers, scientists, and professionals. Their efficiencies in performing their particular tasks are defined in the following table:

Figure 3.6 – The efficiency of every character in performing each task

Figure 3.6 – The efficiency of every character in performing each task

Let's take a look at how we can implement this scenario. Let's say the player needs to build new houses to accommodate the increased population. A house construction would require 1,000 units of workload to finish. We use the earlier value as the workload that can be done per second per unit type for a particular task.

So, if you're building a house with one worker, it'll only take about 10 seconds to finish the construction (1000/95), whereas it'll take more than 3 minutes if you are trying to build with the scientists (1000/5 = 200 seconds). The same is true for other tasks, such as research and development and corporate jobs. Of course, these factors can be adjusted or enhanced later as the game progresses, making some entry-level tasks simpler and taking less time.

Then, we introduce special items that the particular unit type can discover. We don't want to give out these items every time a particular unit has done its tasks. Instead, we want to reward the player as a surprise. So, we associate the probability of finding such items according to the unit type, as described in the following table:

Figure 3.7 – The probability of finding specific objects for each unit type

Figure 3.7 – The probability of finding specific objects for each unit type

The preceding table shows a 30% chance of a worker finding some raw materials and a 10% chance of earning bonus income whenever they have built a factory or a house. This allows the players to anticipate possible upcoming rewards once they've done some tasks and make the game more fun because they do not know the event's outcome.

Perceived randomness

One critical aspect of randomness is that humans are terrible at understanding true randomness. Instead, when us humans talk about random results, we think of equally distributed results. For example, imagine a Massive Online Battle Arena (MOBA) game such as League of Legends. Imagine that we have a hero with an ability that does colossal damage but only hits 50% of the time. The player starts a game with such a hero, but the hero misses that ability five times in a row due to bad luck. Put yourself in the shoes of that player – you would think that the computer is cheating or that there is something wrong, right?

However, getting 5 consecutive misses has a probability of 1 over 32. That is about 3.1%, more than getting three of a kind in a five-card deal of poker (which is about 2.1%) – unlikely but possible. If our game uses a perfectly random number generator, we may get this scenario relatively often.

Let's put it another way. Given a sequence of misses (M) and hits (H), which sequence do you find more random between HHHHHMMM and HMHMHHMH? I bet the second one, where we interleave misses and hits. It feels more random than the first one (where hits and misses are nicely grouped in strikes), even if they have the exact same chance of occurring naturally.

The point is that, sometimes, for the sake of player engagements, games need to tweak their randomness to get something that feels more random than true randomness. Video games do that in several ways. The most common one is keeping track of the number of occurrences of a value that should be perceived as random.

So, for instance, we may keep track of the number of hits and misses of our hero's ability, and when we see that the ratio between the two get too far away from the theoretical one of 50% – for example, when we have 75% misses (or hits) – we rebalance the ratio by forcing a hit (or vice versa).

FSM with probability

In Chapter 2, Finite State Machines, we saw how to implement an FSM using simple switch statements or the FSM framework. We based the decision on choosing which state to execute purely on a given condition's true or false value. Let's go back for a moment to the FSM of our AI-controlled tank entity:

Figure 3.8 – The tank AI FSM

Figure 3.8 – The tank AI FSM

For the sake of the example, we can give our tank entities some options to choose from instead of doing the same thing whenever it meets a specific condition. For example, in our earlier FSM, our AI tank would always chase the player tank once the player was in its line of sight. Instead, we can split the player on sight transaction and connect it to an additional new state, Flee. How can the AI decide which state to move to? Randomly, of course:

Figure 3.9 – FSM using probability

Figure 3.9 – FSM using probability

As shown in the preceding diagram, instead of chasing every time, now, when the AI tank spots the player, there's a 50% chance that it'll flee the scene (maybe to report the attack to the headquarters or something else). We can implement this mechanism the same way we did with our previous dice example. First, we must randomly get a value between 1 and 100 and see whether the value lies between 1 and 50, or 51 and 100. If it's the former, the tank will flee; otherwise, it will chase the player.

Another way to implement a random selection is by using the roulette wheel selection algorithm. This algorithm is advantageous when we do not have exact probabilities or know all the possible options at compile time (for instance, because we load the FSM rules from a file).

As the name suggests, the idea is to imagine a roulette wheel with one sector for each event. However, the more probable an event is, the larger the sector is. Then, we mathematically spin the wheel and choose the event corresponding to the sector where we ended up:

Figure 3.10 – The roulette wheel

Figure 3.10 – The roulette wheel

In our example, we have three states: Chase, Flee, and SelfDestruct. We assign a weight to each state, representing how probable they are with respect to each other. For instance, in the figure, you can see that I set Chase with weight 80, Flee with weight 19, and SelfDestruct with weight 1. Note that weights do not need to sum to 1 like probabilities, nor 100, nor anything in particular.

In this case, however, I made them add to 100 because it is easier to translate weights into probabilities: we can imagine Chase happening 80% of the time, Flee 19% of the time, and in 1% of the cases, the tank self-destructing. However, in general, you can imagine the weight of event X as the number of balls with X written on them and put inside a lottery box.

Let's see the result in the FSM.cs file:

using UnityEngine;

using System.Collections;

using System;

using System.Linq;

public class FSM : MonoBehaviour {

    [Serializable]

    public enum FSMState {

        Chase,

        Flee,

        SelfDestruct,

    }

    [Serializable]

    public struct FSMProbability {

        public FSMState state;

        public int weight;

    }

    public FSMProbability[] states;

    FSMState selectState() {

        // Sum the weights of every state.

        var weightSum = states.Sum(state => state.weight);

        var randomNumber = UnityEngine.Random.Range(0,

          weightSum);

        var i = 0;

        while (randomNumber >= 0) {

            var state = states[i];

            randomNumber -= state.weight;

            if (randomNumber <= 0) {

                return state.state;

            }

            i++;

        }

        // It is not possible to reach this point!

        throw new Exception("Something is wrong in the

          selectState algorithm!");

    }

    

    // Update is called once per frame

    void Update () {

        if (Input.GetKeyDown(KeyCode.Space))

        {

            FSMState randomState = selectState();

            Debug.Log(randomState.ToString());

        }

    }

}

The mechanism is straightforward. First, we sum all the weights to know the size of the imaginary wheel. Then, we pick a number between 0 and this sum. Finally, we subtract from this number the weights of each state (starting from the first one) until the number gets negative. Then, as you can see in the Update() method, every time we press the Spacebar, the algorithm chooses one random item from our states array.

Dynamically adapting AI skills

We can also use probability to specify the intelligence levels of AI characters or the global game settings, affecting, in turn, a game's overall difficulty level to keep it challenging and exciting for the players. As described in the book The Art of Game Design by Jesse Schell, players only continue to play a game if the game keeps them in the flow channel (a concept adapted to games from the psychological works on flow state of Mihály Csíkszentmihályi):

Figure 3.11 – The player's flow channel

Figure 3.11 – The player's flow channel

If we present too tricky challenges to the players before they have the necessary skills, they will feel anxious and disappointed. On the other hand, once they've mastered the game, they will get bored if we keep it at the same pace. The area in which the players remain engaged for a long time is between these two hard and easy extremes, which the original author referred to as the flow channel. To keep the players in the flow channel, the game designers need to feed challenges and missions that match the increasing skills that the players acquire over time. However, it is not easy to find a value that works for all players, since the pace of learning and expectations can differ from individual to individual.

One way to tackle this problem is to collect the player's attempts and results during the gameplay sessions and to adjust the difficulty of the opponent's AI accordingly. So, how can we change the AI's difficulty – for instance, by making the AI more aggressive, increasing the probability of landing a perfect shot, or decreasing the probability of erratic behavior?

Creating a slot machine

In this demo, we will design and implement a slot machine game with 10 symbols and 3 reels. To make it simple, we'll use the numbers from zero to nine as our symbols. Many slot machines use fruit and other simple shapes, such as bells, stars, and letters. Some other slot machines use a specific theme based on popular movies or TV franchises. Since there are 10 symbols and 3 reels, that's a total of 1,000 (10^3) possible combinations.

A random slot machine

This random slot machine demo is similar to our previous dice example. This time, we are going to generate three random numbers for three reels. The only payout will be when we get three of the same symbols on the pay line. To make it simpler, we'll only have one line to play against in this demo. If the player wins, the game will return 500 times the bet amount.

We'll set up our scene with all our UI elements: three texts for the reels, another text element for the YOU WIN or YOU LOSE text (the betResult object), one text element for the player's credits (Credits), an input field for the bet (InputField), and a button to pull the lever (Button):

Figure 3.12 – Our GUI text objects

Figure 3.12 – Our GUI text objects

This is how our new script looks, as shown in the following SlotMachine.cs file:

using UnityEngine;

using UnityEngine.UI;

public class SlotMachine : MonoBehaviour {

    public float spinDuration = 2.0f;

    public int numberOfSym = 10;

    public Text firstReel;

    public Text secondReel;

    public Text thirdReel;

    public Text betResult;

    public Text totalCredits;

    public InputField inputBet;

    private bool startSpin = false;

    private bool firstReelSpinned = false;

    private bool secondReelSpinned = false;

    private bool thirdReelSpinned = false;

    private int betAmount;

    private int credits = 1000;

    private int firstReelResult = 0;

    private int secondReelResult = 0;

    private int thirdReelResult = 0;

    private float elapsedTime = 0.0f;

First, we start by listing all the class attributes we need. Again, note that it is a good programming practice to avoid public fields unless strictly necessary. Therefore, you should use the [SerializeField] attribute instead. Here, however, we will use the public attribute to avoid making the code listing too long.

Now, let's continue by adding three new functions: Spin, which starts the spinning of the slot machine; OnGui, which we will use to update the user interface; and checkBet, a function that checks the result of the spin and informs the players if they win or lose:

public void Spin() {

    if (betAmount > 0) {

        startSpin = true;

    } else {

        betResult.text = "Insert a valid bet!";

    }

}

private void OnGUI() {

    try {

        betAmount = int.Parse(inputBet.text);

    } catch {

        betAmount = 0;

    }

    totalCredits.text = credits.ToString();

}

void checkBet() {

    if (firstReelResult == secondReelResult &&

        secondReelResult == thirdReelResult) {

        betResult.text =

          "YOU WIN!"; credits += 500*betAmount;

    } else {

        betResult.text = "YOU LOSE!"; credits -= betAmount;

    }

}

Next, we implement the main loop of the script. In the FixedUpdate function, we run the slot machine by spinning each reel in turn. In the beginning, firstReelSpinned, secondReelSpinned, and thirdReelSpinned are all false. Therefore, we enter in the first if block. Here, we set the reel to a random value, and we end the function. We repeat that until a certain amount of time has passed.

After that, we set the reel to the final value, and we set firstReelSpinned to true. Then, the function will move to the second reel, where we repeat these steps. Finally, after the third reel is finally set to its final value, we check the results with checkBet:

void FixedUpdate () {

    if (startSpin) {

        elapsedTime += Time.deltaTime;

        int randomSpinResult =

          Random.Range(0, numberOfSym);

        if (!firstReelSpinned) {

            firstReel.text = randomSpinResult.ToString();

            if (elapsedTime >= spinDuration) {

                firstReelResult = randomSpinResult;

                firstReelSpinned = true;

                elapsedTime = 0;

            }

        } else if (!secondReelSpinned) {

            secondReel.text = randomSpinResult.ToString();

            if (elapsedTime >= spinDuration) {

                secondReelResult = randomSpinResult;

                secondReelSpinned = true;

                elapsedTime = 0;

            }

        } else if (!thirdReelSpinned) {

            thirdReel.text = randomSpinResult.ToString();

            if (elapsedTime >= spinDuration) {

                thirdReelResult = randomSpinResult;

                startSpin = false;

                elapsedTime = 0;

                firstReelSpinned = false;

                secondReelSpinned = false;

                checkBet();

            }

        }

    }

}

Attach the script to an empty GameController object and then fill in the referenced object in the Inspector. Then, we need to connect Button to the Spin() method. To do that, select Button and fill the On Click () event handler in the Inspector, as shown in the following screenshot:

Figure 3.13 – The On Click() event handler

Figure 3.13 – The On Click() event handler

When we click the button, we set the startSpin flag to true. Once spinning, in the FixedUpdate() method, we generate a random value for each reel. Finally, once we've got the value for the third reel, we reset the startSpin flag to false. While we are getting the random value for each reel, we also track how much time has elapsed since the player pulled the lever.

Usually, each reel would take 3 to 5 seconds before landing the result in real-world slot machines. Hence, we also take some time, as specified in spinDuration, before showing the final random value. If you play the scene and click on the Pull Lever button, you should see the final result, as shown in the following screenshot:

Figure 3.14 – Our random slot game in action

Figure 3.14 – Our random slot game in action

Since your chance of winning is 1 out of 100, it quickly becomes tedious, as you lose several times consecutively. However, if you've ever played a slot machine, this is not how it works, or at least not anymore. Usually, you can have several wins during your play. Even though these small wins don't recoup your principal bet (and in the long run, most players go broke), the slot machines still occasionally render winning graphics and exciting sounds, which researchers refer to as losses disguised as wins.

So, instead of just one single way to win the jackpot, we want to modify the rules a bit so that the slot machine pays out smaller returns during the play session.

Weighted probability

Real slot machines have something called a Paytable and Reel Strips (PARS) sheet, which is the complete design document of the machine. The PARS sheet is used to specify the payout percentage, the winning patterns, their payouts, and so on.

The number of payout prizes and the frequencies of such wins must be carefully selected so that the house (the slot machine) always wins in the long run while making sure to return something to the players from time to time to make the machine attractive to play. This is known as payback percentage or Return to Player (RTP). For example, a slot machine with a 90% RTP means that, over time, the machine returns an average of 90% of all bets to the players.

In this demo, we will not focus on choosing the house's optimal value to yield specific wins over time, nor maintaining a particular payback percentage. Instead, we will demonstrate how to weight probability for specific symbols showing up more times than usual. So, let's say we want to make the 0 symbols appear 20% more than usual on the first and third reel and return half of the bet as a payout.

In other words, a player only loses half of their bet if they got zero symbols on the first and third reels, essentially disguising a loss as a small win. Currently, the zero symbols have a probability of 1/10th (0.1), or a 10% probability. We'll change this now to a 30% chance of zero landing on the first and third reels, as shown in the following SlotMachineWeighted.cs file (remember to switch to the SlotMachineWeighted component in the example code!):

using UnityEngine;

using System.Collections;

using UnityEngine.UI;

public class SlotMachineWeighted : MonoBehaviour {

    public float spinDuration = 2.0f;

    // Number of symbols on the slot machine reels

    public int numberOfSym = 10;

    public Text firstReel;

    public Text secondReel;

    public Text thirdReel;

    public Text betResult;

    public Text totalCredits;

    public InputField inputBet;

    private bool startSpin = false;

    private bool firstReelSpinned = false;

    private bool secondReelSpinned = false;

    private bool thirdReelSpinned = false;

    private int betAmount = 100;

    private int credits = 1000;

    [Serializable]

    public struct WeightedProbability {

        public int number;

        public int weight;

    }

    private List<WeightedProbability> weightedReelPoll =

      new List<WeightedProbability>();

    private int zeroProbability = 30;

    private int firstReelResult = 0;

    private int secondReelResult = 0;

    private int thirdReelResult = 0;

    private float elapsedTime = 0.0f;

New variable declarations are added, such as zeroProbability, to specify the probability percentage of the zero symbols landing on the first and third reels. For example, if zeroProbability is 30, the third reel will show 0 30% of the time. The weightedReelPoll array list is used to fill the weighted symbols, as we did in our earlier FSM example.

Then, we initialize this list in the Start() method, as shown in the following code:

void Start () {

    weightedReelPoll.Add(new WeightedProbability {

        number = 0,

        weight = zeroProbability

    });

    int remainingValuesProb = (100 - zeroProbability)/9;

    for (int i = 1; i < 10; i++) {

        weightedReelPoll.Add(new WeightedProbability {

        number = i,

        weight = remainingValuesProb

    });

}}

In practice, we set the value for 0 to 30, and we split the remaining 70 percentage points between the remaining 9 numbers.

We are also writing a revised and improved checkBet() method. Instead of just one jackpot win option, we are now considering five conditions of jackpot: loss disguised as a win, a near miss, any two symbols matched on the first and third row, and of course, the lose condition:

void checkBet() {

    if (firstReelResult == secondReelResult &&

        secondReelResult == thirdReelResult) {

        betResult.text = "JACKPOT!";

        credits += betAmount * 50;

    } else if (firstReelResult == 0 &&

               thirdReelResult == 0) {

        betResult.text =

          "YOU WIN " + (betAmount/2).ToString();

        credits -= (betAmount/2);

    } else if (firstReelResult == secondReelResult) {

        betResult.text = "AWW... ALMOST JACKPOT!";

    } else if (firstReelResult == thirdReelResult) {

        betResult.text =

          "YOU WIN " + (betAmount*2).ToString();

        credits -= (betAmount*2);

    } else {

        betResult.text = "YOU LOSE!";

        credits -= betAmount;

    }

}

In the checkBet() method, we designed our slot machine to return 50 times the bet if they hit the jackpot, to lose 50% of their bet if the first and third reels are 0, and to win twice if the first and third reels match with any other symbol.

Then, as in the previous example, we generate values for the three reels in the FixedUpdate() method, as shown in the following code:

private int PickNumber() {

    // Sum the weights of every state.

    var weightSum =

      weightedReelPoll.Sum(state => state.weight);

    var randomNumber =

      UnityEngine.Random.Range(0, weightSum);

    var i = 0;

    while (randomNumber >= 0) {

        var candidate = weightedReelPoll[i];

        randomNumber -= candidate.weight;

        if (randomNumber <= 0) {

            return candidate.number;

        }

        i++;

    }

    // It should not be possible to reach this point!

    throw new Exception("Something is wrong in the

                         selectState algorithm!");

}

void FixedUpdate () {

    if (startSpin) {

        elapsedTime += Time.deltaTime;

        int randomSpinResult =

          Random.Range(0, numberOfSym);

        if (!firstReelSpinned) {

            firstReel.text = randomSpinResult.ToString();

            if (elapsedTime >= spinDuration) {

                int weightedRandom = PickNumber();

                firstReel.text = weightedRandom.ToString();

                firstReelResult = weightedRandom;

                firstReelSpinned = true;

                elapsedTime = 0;

            }

        } else if (!secondReelSpinned) {

            secondReel.text = randomSpinResult.ToString();

            if (elapsedTime >= spinDuration) {

                secondReelResult = randomSpinResult;

                secondReelSpinned = true;

                elapsedTime = 0;

            }

        }

...

For the first reel, we show the real random values as they occur during the spinning period. Once the time is up, we choose the value from the poll that is already populated with symbols according to the probability distribution. So, our zero symbols will have a 30% better chance of occurring than the rest.

In reality, the player is losing on their bets if they get two zero symbols on the first and third reel; however, we make it seem like a win. It's just a lame message here, but this can work if we combine it with nice graphics, maybe even fireworks, and nice winning sound effects.

A near miss

If the first and second reels return the same symbol, we have to provide the near-miss effect to the players by returning the random value to the third reel close to the second one. We can do this by checking the third random spin result first. If the random value is the same as the first and second results, this is a jackpot, and we shouldn't alter the result.

But if it's not, then we should modify the result so that it is close enough to the other two. Check the comments in the following code:

        else if (!thirdReelSpinned) {

            thirdReel.text = randomSpinResult.ToString();

            if (elapsedTime >= spinDuration) {

                if ((firstReelResult == secondReelResult)

                  && randomSpinResult != firstReelResult) {

                    // the first two reels have resulted

                    // the same symbol

                    // but unfortunately the third reel

                    // missed

                    // so instead of giving a random number

                    // we'll return a symbol which is one

                    // less than the other 2

                    randomSpinResult = firstReelResult - 1;

                    if (randomSpinResult < firstReelResult)

                      randomSpinResult =

                        firstReelResult - 1;

                    if (randomSpinResult > firstReelResult)

                     randomSpinResult =

                       firstReelResult + 1;

                    if (randomSpinResult < 0)

                      randomSpinResult = 0;

                    if (randomSpinResult > 9)

                      randomSpinResult = 9;

                    thirdReel.text =

                      randomSpinResult.ToString();

                    thirdReelResult = randomSpinResult;

                } else {

                    int weightedRandom = PickNumber();

                    thirdReel.text =

                      weightedRandom.ToString();

                    thirdReelResult = weightedRandom;

                }

                startSpin = false;

                elapsedTime = 0;

                firstReelSpinned = false;

                secondReelSpinned = false;

                checkBet();

            }

        }

    }

}

And if that near miss happens, you should see it, as shown in the following screenshot:

Figure 3.15 – A near miss

Figure 3.15 – A near miss

We can go even further by adjusting the probability in real time, based on the bet amount (but that'd be too shady). Finally, we can add a Game Over message that appears when the player has bet all their money.

This demo shows you the basic implementation of a slot machine game. You can start from this skeleton and improve it with nicer graphics, animations, and sound effects. The important takeaway, though, is understanding that you can already create a game with randomness and probability alone.

Summary

In this chapter, we learned about the applications of probability in AI game design. We experimented with some of the techniques by implementing them in Unity3D. As a bonus, we also learned about how a slot machine works and implemented a simple slot machine game using Unity3D. Probability in games is about making the game, and the characters, seem more realistic by adding uncertainty to their behavior so that players cannot predict the outcome.

In the next chapter, we will look at implementing sensors and how they can make our AI aware of its surroundings.

Further reading

To further study the advanced techniques on probability in games, such as decision making under uncertainty using Bayesian techniques, I recommend reading AI for Game Developers by David M. Bourg and Glenn Seeman. Rules of Play by Katie Salen is another suggested book on game design.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
13.59.231.155