The Nuances of Math.random() and Rounding

Photo by Erik Mclean on Unsplash

The Nuances of Math.random() and Rounding

Breaking Down the Classic "Dice Roll" Beginner Exercise

Preface: I am a new developer, immersing myself in anything and everything JavaScript.

It has been a few months since I last wrote a post, as I have been busy with life things, and the Full Stack Engineer track/path on Codecademy--mostly working on their HTML/CSS projects. In that time, my basic JS knowledge was sort of going by the wayside. I returned to JS--specifically to this small-time learning tool, JS Hero .

On a personal level, it is a little disheartening to go from the code I wrote in the past posts, to this.

What is Dice Roll?

A couple of the exercises/lessons [there are two links there] on JS Hero were used in the classic "Dice Roll" teaching tool for Math.random(), Math.round(), Math.ceil(), and Math.floor(). That is, the dice() function should return a number that is from 1-6 inclusive to represent the six faces of a standard dice.

While I have done these before, it had been a while, and some of my recent initial attempts did not produce the outcome dictated by the exercise. However, through trial & error, I eventually wrote the code that produced the desired outcome.

At three lines (counting the function declaration itself) the code itself is of course not complex. There are however some nuanced concepts--at least from the perspective of a beginner--about how it actually works that I thought would be a good idea to discuss in a "thinking out loud" type way.

  • In typical JS fashion, there are actually [at least] three separate ways to "solve" how to generate random numbers from 1-6 (inclusive)

  • I use arrow functions because they are prettier, but I've also provided what the function declaration would look like in its more standard or old school format

  • The Steps in the solutions below are the "steps" taken by the function(s) as they evaluate the code. They can also be the steps taken by a human if they were to solve for this on paper.

tl;dr

The method you chose, the numbers you chose, and the arithmetic (if any) you chose will affect the outcome in surprisingly different ways.

Solutions

As previously stated, there are at least three separate solutions. Some use arithmetic, some don't. One solution uses 5 in conjunction with Math.random(), while the others use 6.

Solution: Using Math.floor()

const dice1 = () => { // function dice1() {
  return Math.floor(Math.random() * 6) + 1;
}; // Correct

Step 1

This works in part because "any number less than one" multiplied by six results in a number that is than six. The "number less than one" is generated by Math.random(). Read more about Math.random() here.

For example, 0.97 * 6 = 5.82, 0.371 * 6 = 2.226, 0.0514 * 6 = 0.3084 and so on. The numbers we care about are 5.82, 2.226, and 0.3084. All are the result of Math.random() * 6.

Step 2

Once the function has the number produced by Math.random() * 6, the function will it round down [to the "floor"] via Math.floor(). Using the same examples as above, 5.82 rounds down to 5, 2.226 rounds down to 2, and 0.3084 rounds down to 0. At this point, the numbers we care about are 5, 2, and 0.

Now we have our rounded numbers, but, as seen above, 0 was one of the numbers produced by Math.floor(Math.random() * 6). We don't want 0. We want 1-6 inclusive. In fact, based on Math.floor(Math.random() * 6) alone, we can't actually generate 6 either. We want 6.

Step 3

To generate numbers within 1-6 inclusive [i.e. not including 0, but including 6], the function adds 1 to the number produced by Math.floor(Math.random() * 6). Again using the original three examples, 5 + 1 = 6, 2 + 1 = 3, and 0 + 1 = 1. Now the numbers we care about are 6, 3, and 1. All are the result of Math.floor(Math.random() * 6) + 1.

Note how all of the numbers we ended up with are withing our desired range, inclusive.

There is however a "more elegant" solution--in that it has less steps [by using it, we don't have to add 1.]

Solution: Using Math.ceil()

const dice3 = () => { // function dice3() {
  return Math.ceil(Math.random() * 6);
}; // Correct

Step 1

This step in this solution is the same as it is in the Math.floor() solution.

That is, Math.random() * 6 produces random numbers that are less than six. For example, 5.82, 2.226, and 0.3084. [0.97 * 6 = 5.82, 0.371 * 6 = 2.226, 0.0514 * 6 = 0.3084 and so on.]

Step 2

This is where things in this solution deviate from the Math.floor() solution. Unlike Math.floor(), Math.ceil() rounds up [to the "ceiling"]. That is, 5.82 rounds up to 6, 2.226 rounds up to 3, and 0.3084 rounds up to 1.

At this point, we already have numbers solely within our desired range of 1-6 inclusive. It will not produce 0. There is no Step 3.

Solution: Using Math.round()

const dice2 = () => { // function dice2() {
  return Math.round(Math.random() * 5) + 1;
}; // Correct

Note how the 6 changes to a 5. Read on for the "why".

In a sense, Math.round() is conditional. It works in a way similar to how humans are taught how to round, as seen in the below examples.

  • 23 rounded to the nearest multiple of 5 is 25 [it rounds up]

  • 52 rounded to the nearest multiple of 10 is 50 [it rounds down]

  • 384 rounded to the nearest multiple of 10 is 380 [it rounds down]

  • 384 rounded to the nearest multiple of 100 is 400 [it rounds up]

Using the same numbers as before and considering how Math.round() works conditionally [it rounds up or down to the nearest integer], 5.82 rounds up to 6, 2.226 rounds up to 3, and 0.3084 rounds down to 0.

In using this solution however, we have to change the number 6 to the number 5. We also have the third step of the function addding 1.

Without changing the 6 to a 5, Math.round() will round 5.82 up to 6. 1 is then added to 6, resulting in 7. 7 is outside of the range we are looking for. However, if we change the 6 to a 5, any numbers being rounded up by Math.round() will not be greater than 6.

Non-solutions

Now that you have an understanding of what works, check out the below. Try to understand why these don't work.

Non-solutions: Can return 7

Each one of these can result in seven. Ask yourself why. Think about it.

const dice4 = () => { // function dice4() {
  return Math.floor(Math.random() * 7) + 1;
}; // Wrong - Can return 7

const dice5 = () => { // function dice5() {
  return Math.round(Math.random() * 6) + 1;
}; // Wrong - Can return 7

const dice6 = () => { // function dice6() {
  return Math.ceil(Math.random() * 6) + 1;
}; // Wrong - Can return 7

Non-solution: dice9()

The dice9() function is referenced below. Just looking at it, you might expect it to be able to produce a range of numbers from one to six. You might think, "Seven minus one is six ... there are six faces of the dice ... I'm randomizing a number, then rounding it!"

const dice9 = () => {
  return Math.floor(Math.random() * 7) - 1;
}; // Wrong - Can return -1

Why doesn't it work? Why does it have a chance to return -1?

Other non-solutions

Notice how the methods, the numbers, and the arithmetic change from function to function.

const dice7 = () => {
  return Math.floor(Math.random() * 6);
}; // Wrong - Can return 0

const dice8 = () => {
  return Math.round(Math.random() * 7) + 1;
}; // Wrong - Can return 8

const dice9 = () => {
  return Math.floor(Math.random() * 7) - 1;
}; // Wrong - Can return -1

const dice10 = () => {
  return Math.round(Math.random() * 7) - 1;
}; // Wrong - Can return -1

const dice11 = () => {
  return Math.ceil(Math.random() * 7) - 1;
}; // Wrong - Can return zero

const dice12 = () => {
  return Math.ceil(Math.random() * 5) + 1;
}; // Wrong - Does not return 1

Testing

Feel free to test all of these solutions and non-solutions with the below function. It will push ninety-nine dice rolls to an array. All you have to do is change the dice number in the array's .push() method nested in the for loop.

const testDice = () => {
  let diceRolls = 99;
  let arr = [];

  for (let i = diceRolls; i > 0; i--) {
    arr.push(dice4()); // Change the dice number here (1-12)
  }

  return arr;
};

console.log(testDice());

Ninety-nine rolls using dice3()

The correct range of numbers is produced.

[
  3, 3, 1, 5, 5, 1, 5, 6, 3, 3, 5, 4,
  4, 5, 2, 2, 2, 3, 1, 3, 2, 2, 3, 5,
  1, 6, 5, 6, 3, 4, 4, 5, 5, 5, 1, 3,
  5, 6, 1, 4, 2, 1, 2, 3, 3, 5, 6, 2,
  6, 1, 6, 3, 5, 3, 2, 3, 1, 4, 2, 5,
  6, 1, 5, 6, 3, 4, 6, 3, 6, 2, 1, 5,
  6, 5, 1, 6, 6, 3, 2, 2, 3, 1, 3, 6,
  2, 6, 4, 5, 4, 6, 3, 1, 1, 1, 3, 3,
  6, 3, 5
]

Ninety-nine rolls using dice9()

Notice there are no instances of 6, and that there are several instances of 0 and -1.

[
   1,  2,  5,  3,  4, 5, 0, -1,  5,  0, 3,  0,
   1,  0, -1, -1,  5, 3, 1, -1, -1,  0, 4, -1,
  -1, -1,  4, -1,  3, 4, 4,  2,  5,  3, 0, -1,
   2,  2, -1,  5, -1, 1, 2,  0,  3,  4, 5,  0,
   2,  0,  1,  5,  3, 4, 1,  1,  5,  5, 3,  2,
   4,  0,  3,  5,  2, 5, 5,  1,  3, -1, 5, -1,
  -1,  5,  4,  2,  4, 3, 5,  0,  4,  5, 1,  1,
   1,  4,  3,  1,  4, 4, 2, -1,  1,  0, 1,  2,
  -1,  3,  5
]

Ninety-nine rolls using dice12()

Notice there are no instances of 1

[
  3, 6, 2, 4, 4, 6, 6, 2, 5, 6, 4, 2,
  5, 2, 6, 5, 6, 3, 4, 6, 4, 3, 6, 2,
  3, 6, 4, 6, 5, 6, 2, 3, 2, 4, 5, 3,
  3, 5, 2, 2, 6, 5, 3, 6, 6, 6, 2, 5,
  2, 2, 6, 3, 4, 6, 5, 2, 5, 2, 2, 3,
  4, 2, 3, 5, 3, 4, 2, 6, 3, 2, 3, 6,
  5, 6, 5, 6, 2, 2, 3, 3, 6, 2, 5, 4,
  3, 5, 4, 2, 2, 4, 2, 2, 4, 2, 4, 6,
  4, 2, 3
]