The ability to simulate randomness plays a surprisingly big role in creating software. In the context of graphics, for example, we often use randomness to simulate natural phenonema, like wind and other random effects.
In a role playing game it can be used implement things like:
We'll implement the last two of these in this lesson. This will mean our combat outcome is not always the same.
We'll be able to write code like this:
class Character {
// Each attack can do between 15 and 25 damage
int MinDamage { 15 };
int MaxDamage { 25 };
// Character has a 20% chance to avoid damage
float DodgeChance { 0.2 };
void Act(Character* Target) {
Target->TakeDamage(
Random::Int(MinDamage, MaxDamage);
)
}
void TakeDamage(int Damage) {
if (Random::Bool(DodgeChance)) {
return;
}
Health -= Damage;
}
};
The new concepts here, and the key to getting this working, are these Random::Int
and Random::Bool
functions.
We'll create these functions in a file called Random.h
that we can include into our other files as needed. We will also wrap our code in a namespace.
// Random.h
#pragma once
namespace Random {
// Our code here
}
Generating our random numbers involves 3 functions:
Lets get started!
The process of getting a random number from a computer is not as simple as it might seem. Computers are, by design, deterministic machines.
Instead, how we simulate randomness is to source some input that is unpredictable, and then perform mathematical operations on that input every time we want to generate a new random number.
This initial unpredictable input is called the seed. In this lesson, we will simply ask the operating system to provide this seed.
Operating systems have built in techniques to generate random numbers.
How they do this varies, but typically, a combination of arbitrary timing data form a large part of the algorithm.
For example, combining the current time, the time the computer was started, and the timing of the previous 100 keyboard or mouse inputs can be used to deterministically generate a number, and for that number to be completely unpredictable.
Layered on top of this, many devices have small hardware random number generators included on their chips. These devices generate random numbers using small sensors that measure physical effects, such as ambient noise.
To get access to this, we need to <include>
the random
module from the standard library. We can then create a variable of type std::random_device
.
// Random.h
#pragma once
#include <random>
namespace Random {
using namespace std;
random_device seeder;
}
The process to generate a seed is quite resource intensive, so it is not something we want to do frequently.
Instead, the best practice is to take that seed, and pass it into a random number engine that can quickly generate new numbers.
A common mathmatical algorithm for doing this is called the Mersenne Twister
An implementation of this is also available in the standard library, under std::mt19937
. We can initialise our engine using this algorithm, passing it a seed value generated from our seeder.
// Random.h
#pragma once
#include <random>
namespace Random {
using namespace std;
random_device seeder;
mt19937 engine { seeder() };
}
Typically, when we are using randomness, we want it to be constrained to a certain range of possibilities.
For example, we want a damage value to be somewhere between 15 and 25.
To do this, we need to remap the possible outputs from our random number engine to the inputs within this range.
The standard library has std::uniform_int_distribution
which can help us here.
To use it, we create an object of this type, calling the constructor with two integers - the lower and upper limit of the range. To generate a random number in this range, we then "call" the object, passing in our engine.
std::uniform_int_distribution get { 1, 10 };
// Get a random integer from 1 to 10
get(engine);
The above code seems weird - we have an object of type std::uniform_int_distribution
, but we appear to be treating it as a function.
Based on what we've learnt so far, we'd expect to call a function on an object, but not call an object directly.
The key to understanding what is happening here is that ()
is an operator in C++. That means it can be overloaded within a class, and that's exactly what the developers who created std::uniform_int_distribution
did.
This is an example of something called a functor
- we cover this in more detail in the advanced course.
Lets see all our components working together:
#pragma once
#include <random>
namespace Random {
using namespace std;
random_device seeder;
mt19937 engine { seeder() };
int Int(int min, int max) {
uniform_int_distribution get { min, max };
return get(engine);
}
}
Now, within any of our other classes, we can import this header file, and start generating random integers. We just need to call Random::Int
with a lower and upper limit:
#include "Random.h"
class Character {
// Each attack can do between 15 and 25 damage
int MinDamage { 15 };
int MaxDamage { 25 };
void Act(Character* Target) {
Target->TakeDamage(
Random::Int(MinDamage, MaxDamage);
)
}
};
Here, we're using a uniform distribution, but other options are available. In a uniform distribution, each number in the range is equally likely to be chosen. In this example where our range is 15-25, a uniform distribution means that 15, 17 and 19 are going to be equally common results.
Another common strategy is to use a normal distribution. In a normal distribution, the closer a number is to the centre of the range, the more frequently it will be chosen. In this exampe, that means 15 will rarely occur, 17 will be more common, and 19 will be chosen frequently.
If we choose 100 random integers from 15-25, the following chart shows how frequently we might expect each number to be chosen, depending on our choice of distribution:
A typical requirement involves causing an event to happen a specific proportion of the time. For example, maybe a character dodges 20% of attacks, or they perform a critical strike 10% of the time.
It would be useful to be able to encapsulate this within our Random
namespace.
Lets use a different distributor function this time - std::uniform_real_distribution
distributes our random numbers to a range of possible floats.
By distributing from 0.0
to 1.0
, and then comparing it to our desired probability, we can return true
that proportion of the time.
#pragma once
#include <random>
namespace Random {
using namespace std;
random_device seeder;
mt19937 engine { seeder() };
int Int(int min, int max) {
uniform_int_distribution get { min, max };
return get(engine);
}
bool Bool(float probability) {
uniform_real_distribution get { 0.0, 1.0 };
return probability > get(engine);
}
}
And lets see how we can use it in one of our classes:
#include "Random.h"
class Character {
public:
void TakeDamage(int Damage) {
if (Random::Bool(DodgeChance)) {
return;
}
Health -= Damage;
}
private:
// Character has a 20% chance to avoid damage
float DodgeChance { 0.2 };
int Health { 500 };
};
When working with randomness, we often also want some visibility or control of what is happening. For example, it can be difficult to reproduce a bug, or replay a sequence of events, if we have no control over the seed of our random number generator.
Often, we may even want to give users control over the seed. This is common in strategy games for example, where the map might be randomly generated, but we want users to be able to provide the seed.
This allows them to replay a map they enjoyed, or share it with their friends. Without being able to control the seed, that is impossible.
Lets begin by updating our code to store the seed we use as a variable, so we can see what it is. By using auto
deduction ot checking the documentation we see than random_device
uses an unsigned integer.
We'll also add a PrintSeed
function, so we can see what value was chosen.
#include <iostream>
#include <random>
namespace Random {
using namespace std;
random_device seeder;
unsigned int seed { seeder() };
mt19937 engine { seed };
void PrintSeed() {
cout << "Seed: " << seed << endl;
}
int Int(int min, int max) {
uniform_int_distribution get { min, max };
return get(engine);
}
}
int main() {
using namespace std;
Random::PrintSeed();
cout << Random::Int(1, 100) << endl;
cout << Random::Int(1, 100) << endl;
cout << Random::Int(1, 100) << endl;
return 0;
}
Running this code yields the output:
Seed: 141102604
60
78
47
Of course, running the code a second time would generate totally different output. But, now that we know the seed, we could use that to get the same results again, if that is what we (or our player) wanted.
Lets add a Reseed
method to our Random
namespace, which can be called any time we want to reproduce the same results:
#include <iostream>
#include <random>
namespace Random {
using namespace std;
random_device seeder;
unsigned int seed { seeder() };
mt19937 engine { seed };
void PrintSeed() {
cout << "Seed: " << seed << endl;
}
void Reseed(unsigned int NewSeed) {
seed = NewSeed;
engine.seed(NewSeed);
}
int Int(int min, int max) {
uniform_int_distribution get { min, max };
return get(engine);
}
}
Finally, lets make reuse of it in our code, to let us exert some control over our randomness:
int main() {
using namespace std;
// We can still get random numbers:
cout << Random::Int(1, 100) << endl;
cout << Random::Int(1, 100) << endl;
cout << Random::Int(1, 100) << endl << endl;
// But now we can create reproducible results:
Random::Reseed(141102604);
Random::PrintSeed();
cout << Random::Int(1, 100) << endl;
cout << Random::Int(1, 100) << endl;
cout << Random::Int(1, 100) << endl << endl;
cout << "Lets play again!" << endl;
Random::Reseed(141102604);
cout << Random::Int(1, 100) << endl;
cout << Random::Int(1, 100) << endl;
cout << Random::Int(1, 100) << endl;
}
Our output is this:
19
4
97
Seed: 141102604
60
78
47
Lets play again!
60
78
47
In the next lesson, lets apply the techniques we learnt in this section to create more interesting character types!
Become a software engineer with C++. Starting from the basics, we guide you step by step along the way