After working through Gordon Zhu's fantastic "Practical JavaScript" course, I wanted to make an app to put my newfound knowledge to the test. I like World of Warcraft, and I play an Outlaw Rogue in that game, so I created rollthebones, a single page, one-button webapp.
If you're not familiar, "Roll the Bones" is an ability in the game World of Warcraft. The ability allows a player to generate some random "buffs" that enhance the player's character for a brief time.
Under the hood
Roll the Bones works like this:
- A numbered six sided die is rolled six times.
- The numbers that occurred the most are kept, the others are discarded
- The kept numbers are translated into the buffs that the players receive.
To make it less abstract, imagine that your six rolls yielded these results [1,1,1,2,3,4]
. Since 1
is the most common result, your result would just be an array containing [1]
.
A few more examples:
[1,1,2,2,3,4] => [1,2]
[4,5,4,3,5,3] => [3,4,5]
[6,2,3,4,1,5] => [1,2,3,4,5,6]
Those collated results would be translated to corresponding buff(s). There are six buffs, and they're all pirate themed:
- "Grand Melee"
- "Buried Treasure"
- "Jolly Roger"
- "Broadsides"
- "Shark-infested Waters"
- "True Bearing
Simulating a roll
This behavior should be fairly simple to replicate in JavaScript. Let's set up the die first. We can skip the translation from numbers to buff-names just by making each side of the die, instead of being plain numbers, correspond to a buff-name right from the beginning. This has the added benefit of ensuring that we're working with strings the whole way through.
const BUFFS_ARRAY = [
'Grand Melee',
'Jolly Roger',
'Buried Treasure',
'Broadsides',
'Shark Infested Waters',
'True Bearing'
];
The next step is to simulate rolling the die (from now on called "bones", let's be thematic). We can do that by using JavaScript's .map
method.
const rawRoll = () =>
BUFFS_ARRAY.map(
() => BUFFS_ARRAY[Math.floor(Math.random() * BUFFS_ARRAY.length)]
);
}
The code above independently samples a random element from BUFFS_ARRAY
6 times into a new array. It generates a random number between 0 and 5 and returns the element of BUFFS_ARRAY
at that index. It does this BUFFS_ARRAY.length
times because we're running .map
which iterates through an array and returns a new one.
Generating a histogram
The next step is to find out which value occurs most. We can do that in two parts. The first step is to generate a histogram object where each key is a buff name and each value is the number of times that buff occurred in our rawRoll
array.
const generateHistogram = (ary) => {
return ary.reduce((counter, el) => {
counter[el] = counter[el] ? counter[el] + 1 : 1;
return counter;
}, {})
}
ary
in our case will always be a six item array containing only strings that we create with rawRoll();
.
Since we're taking an array and making an object out of it, .reduce
will fit our needs perfectly. We provide {}
as the last parameter to make an empty object the starting point for counter. Then, for each element in the given array, if the element does not exist as a key in our counter object, we create a key and assign it a value of one, letting us know that the element has occurred once so far. Otherwise we simply increment the value of that key by 1 to account for the fact that it has shown up again.
Recap
Let's recap. At this point we can roll the bones six times and generate a histogram of how often we get each buff. If we call rawRoll();
in our browser's console we might get something like this:
let x = rawRoll();
Array [ "Shark Infested Waters", "True Bearing", "True Bearing", "True Bearing", "Jolly Roger", "Grand Melee" ]
And if we call generateHistogram();
on that array we end up with an object that looks like this:
generateHistogram(x)
Object { Shark Infested Waters: 1, True Bearing: 3, Jolly Roger: 1, Grand Melee: 1 }
If these are our results, then our character would end up with a single buff called "True Bearing" (which is the best one by the way) because it occurred most frequently. We haven't programmed that part yet though, so the next and final step is to keep only the buffs that occurred most often.
Finding the modes
Let's write a method to find the modal values of the object and return the corresponding keys.
const findModes = (histogram) => {
const mode = Math.max(...Object.values(histogram))
return Object.keys(histogram).filter(el => histogram[el] == mode)
}
In this case the triple dot ...
is the spread operator, not the rest operator. Math.max accepts comma separated arguments, each of which is a number, and returns the highest number. We use it here to spread the array of numbers from our histogram's values as the argument to Math.max. This will give us the modal value of our histogram.
We use that modal value to determine which keys (which are at strings like "True Bearing" or "Jolly Roger" or "Grand Melee" etc.) correspond to a frequency that equals that modal value. We filter out the buffs that don't occur the most and keep the ones that do, giving us our final result.
After speaking with a user on the r/learnprogramming discord, I was shown a different implementation.
let max = -1;
let keys = [];
Object.keys(histogram).forEach(key => {
if (histogram[key] > max) {
max = histogram[key];
keys = [key];
} else if (histogram[key] === max) {
keys.push(key);
}
});
I like this implementation a lot. Whereas mine iterated through the given histogram object twice (once to determine the max value, and once to filter out the unwanted keys), the other implementation runs through the histogram a single time, changing the max
value and emptying the keys
array as necessary. This implementation would be better if we're feeding findModes
very very large histograms but for my purposes I'm deciding to stick with my implementation because it's easier to read and correctly guess the purpose of. There's also an imaginary "worst-case" scenario for this implementation in which, because modes
might change constantly, the keys
array might also constantly be being appended to, emptied, and remade. Additionally, this implementation won't work for histograms containing only negative numbers.
Wrapping it up
We now have everything we need to simulate the Roll the Bones ability. And in doing so we were able to apply our knowledge of the three most common functional programming methods in JavaScript: .map
, .filter
, and .reduce
.
Since we only care about the final result of Rolling the Bones and don't care about the underlying functionality, all we need now is a function to abstract away all the little stuff.
const generateRoll = () => return (findModes(generateHistogram(rawRoll())))
That many parentheses always makes me nervous. But now that we've successfully created the core functionality of rolling the bones, everything else will follow.