At my second meet-up of this week, I ventured north to the east 20s for Building JavaScript Games. Our task was to build a seemingly simple game called Strike 9.
How the Game is Played
At the beginning of a turn, the “computer” rolls two die. The player has squares numbered 1 through 9 to make the sum of the computer’s roll. The trick is that when the player uses a number it is removed from the board. So for example, if computer rolls a 9, the player can choose to remove any of the following combinations: [9], [1,8], [2,7], [1,2,6], [2,3,4], etc. If he or she uses, say, the 1 and the 8, on the next roll neither 1 or 8 will be available.
Huh? / Live Demo
Yeah, it’s kind of hard to explain in words. Here’s a live demo (and my GitHub repo).
How I Approached the Basic Gameplay
The meat of this project lives in strike9-board.js
. onload
we initialize some arrays and variables, then call resetGame()
, which draws our 9 HTML canvas
’s and rolls the 2 die for the first time.
From there we wait for the player to click one of the 9 canvases. When a canvas is clicked, we make sure it hasn’t been clicked yet in this game. Then we mark it as clicked in the board_array
and we remove that number from the player_moves_remaining
array. We also add the number to player_total
.
Eventually we have to figure out if the player won, lost, or can keep playing on that turn. Here’s a excerpt of that code:
// the player just made a sum...
// check if player has won
if (board_array.indexOf(0) == -1){
alert("Oh hey, You won!");
resetGame();
} else { //still playing
// re-roll dice.
computer_roll = rollTwoDie();
// with new dice roll, we can already figure out if the game is over
if (isGameOver(computer_roll, player_moves_remaining)){
alert("Computer's next roll is " + computer_roll + "...Game over :(");
resetGame();
} else { // if there exists a way to make the roll's sum...
sendMessage(randPraise() + "<br>New roll is " + computer_roll);
// reset for new roll, same game
possible_combinations = [];
player_total = 0;
}
}
By far the most interesting of these outcomes to check is if the player lost– which we do with the isGameOver()
function called above. I wanted the game to know if the player lost right away– in other words if the player had 1, 2, and 4 remaining and the computer’s new roll was 5, I wanted the game to tell the player they had lost.
This might sounds simple, but– at least the way I went about it– turned out to be pretty non-trivial.
Coolest Challenge: When is the Game Over?
Ideally I wanted a function called isGameOver
that returns true
if the player can’t make the sum with the remaining numbers (like the case above), and false
if the player can make the sum.
My approach was to generate an array of all ways to make a sum (in the above hypothetical, all the ways to add numbers to make 5). So given, say, 6
, getAllPossAddends
should return an array of arrays: [[6], [1,5], [2,4], [1,2,3]]
. Note that we don’t want [3,3]
because there’s only one 3 on the board, and we don’t want [5,1]
because we already have [1,5]
and order doesn’t matter here. And 6
itself should be included because, in line with the rules of the game, if the computer rolls a 6 you can just use the 6 square.
Once we get this array of possible combinations to make a given sum, which I call possible_combinations
, we’ll just need to compare it to what numbers the player has left (player_moves_remaining
) to see if isGameOver
should be true
or false
. Cool? Sounds easy? OMG took me a whole day.
Let’s do this.
Part 1: Generating possible_combinations
Note: The section below has been edited and updated to reflect some significant refactoring I’ve performed.
So the first thing I wanted to do was write the function that would take in a sum
, say 6
and spit back [[1,5], [2,4], [1,2,3], [6]]
. I decided to call this function getAllPossAddends
(“addends”, according to Wikipedia, is the technical term for what you add together to get a sum).
After a few days of tinkering, I wrote a recursive function to accomplish this task.
var possible_combinations = []; // I made this global
function getAllPossAddends(sum, baggage){
// if baggage argument is undefined, this is our first run through
// so set baggage = []
// baggage is effectively an optional argument with a default value of [] if undefined in function call
if (typeof baggage == 'undefined'){
baggage = [];
possible_combinations = [];
possible_combinations.push([sum]); // can just play the number itself
}
for (var j=1; j < sum/2; j++){
// if j is not in baggage and sum-j is not in baggage
if (baggage.indexOf(j) == -1 && baggage.indexOf(sum-j) == -1){
// add j and sum-j back to baggage to make our new array we may add as a possible_combination
var new_array = baggage.concat(j, sum-j);
// if new_array is NOT a subArray of possible_combinations yet...
if (!isSubArray(new_array,possible_combinations)){
possible_combinations.push(new_array);
}
// prep new_baggage and send new sum and baggage back to top of the function
var new_baggage = baggage.concat(j);
getAllPossAddends(sum-j, new_baggage);
}
}
return possible_combinations;
}
Let’s walk through it as if we called it with getAllPossAddends(10)
, so sum = 10
and baggage
is undefined
.
Right off the bat we use an if
statement to check if baggage
is undefined. If it is, we know that this is the “first time” we’re running through getAllPossAddends
, and that we should set baggage = []
and reset possible_combinations
. This setting of baggage
to []
will make a little more sense in a minute.
Now to the meat of the function. First we loop from 1 to sum/2
(5 in our case). We then do some checks against baggage, which we’ll learn about in a minute. Note: this loop doesn’t go up to 5 because we put i < sum/2
not i <= sum/2
, which is what we want– remember there aren’t 2 5s so we don’t want [5,5]
to count as a possible_combination
.
Eventually we make a new_array and set it equal to baggage.concat(j, sum-j)
. We don’t have any baggage yet, so for the first run through, j==1
and sum-j==9
. And that’s good, because [1,9] is a possible way of making 10.
That’s a start, but what about [1,4,5]
or (gulp) [1,2,3,4]
? This is the problem that the recursion handles:
// prep new_baggage and send new sum and baggage back to top of the function
var new_baggage = baggage.concat(j);
getAllPossAddends(sum-j, new_baggage);
Basically, we want to take that 9
in [1,9]
and expand it out to [[1,9], [1,2,7], [1, 3, 6], [1, 4, 5]]
. We want to break that 9 down while keeping the 1 untouched (Notice how the 1
is the first element of every “sub-array”). I decided to call the 1
baggage
, because it’s like, just along for the ride. With the baggage taken care of, what we want to do to the 9 is very similar to what we set out to do the 10– namely find all the ways to sum to it. Sounds like a case for recursion!
We’re going to call getAllPossAddends
again, but first we have to set the new_baggage
. In our example, right after we add [1,9]
, 9 is the sum-j
we want to break down further, and j is 1
, the baggage we want to pass along. We have to use baggage.concat(j)
in case we got any baggage at the beginning of the run-through of the function, which will be the case every time except the first run-through.
On the second run-through in our example, the getAllPossAddends call is: getAllPossAddends(9, [1])
. Now 9 is the new sum
and [1]
is the baggage
(since baggage
is defined, we do not execute that first if
statement).
So the array we prepare, new_array
, WOULD BE [1,1,8], but we have a check against duplicated numbers: if (baggage.indexOf(j) == -1 && baggage.indexOf(sum-j) == -1)
. j
, which is equal to 1
, is in fact present in baggage
, so we’re not going to do anything in this for
iteration. (Note: This is a bit wasteful, but refactoring further may make this thing a little too confusing.)
So we try again. On the next iteration of the for
loop, j
will be equal to 2 and sum-j
== 7. This time we will make it through the if
checks. baggage
is still [1]
, so when we get to baggage.concat(2, 7)
it’s going to evaluate to [1,2,7]
. Then we’ll add it to possible_combinations
.
We’ll then call the function again which sum-j
equal to 7 and baggage
== [1,2]
. So the call will be getAllPossAddends(7, [1,2])
, and we’ll go break down that 7. And so on! Crazy, right?
Part 2: Building My Own Array Tools
You may have noticed my use of a function called isSubArray
. It checks whether the array we’re trying to add to possible_combinations
has already been added (again, we don’t want duplicates).
Of course this function is not a standard JavaScript function, but one I wrote myself. I ended up writing 3 of these “array helper” functions in total.
// believe it or not, array.sort sorts numbers alphabetically, so we have to define our own sort for numbers
function sortArray(array){
return array.sort(function(a, b){return a-b});
}
// this guy takes an array of arrays and sorts all of its sub-arrays using the above function
function sortAllSubArrays(array){
for(var i = 0; i < array.length; i++){
array[i] = sortArray(array[i]);
}
return array;
}
// the workhorse function in the trio. It sorts both the sub-array and the array of arrays using their respective functions.
// then sees if any of the arrays in `array` match the given `subArray`
function isSubArray (subArray, array) {
subArray = sortArray(subArray); // need to sort both the subArray...
array = sortAllSubArrays(array); // and the sub-arrays in array in order to compare them more easily
for(var i = 0; i < array.length; i++) {
if(subArray.toString() === array[i].toString()){
return true;
}
}
return false;
}
Part 3: Putting It All Together
Cool, so now we have an array, possible_combinations
, that’s full of all the ways our player could make a given sum (a given dice roll in our case). As I mentioned above, we also have an array of numbers that the player has left, called player_moves_remaining
. Now all we have to do is compare them to figure out if the game is over.
Again, I broke this into 2 functions, but most of the work happens in playerHasAMove
. player_moves_left
is player_moves_remaining
and passing_moves
is our mega-awesome array of arrays, possible_combinations
.
function playerHasAMove(player_moves_left, passing_moves){
for(var i = 0; i < passing_moves.length; i++) {
var matches = 0;
for(var j = 0; j < passing_moves[i].length; j++) {
if (player_moves_left.indexOf(passing_moves[i][j]) != -1){
// we foudn a match
matches = matches + 1;
}
}
if (matches == passing_moves[i].length){
return true;
}
}
return false;
}
function isGameOver(roll, player_moves_remaining){
var ways_to_fulfill_roll = getAllPossAddends(roll);
if (playerHasAMove(player_moves_remaining, ways_to_fulfill_roll)){
return false; // game is not over
} else {
return true; // game is over
}
}
playerHasAMove
first loops over the passing_moves
and yields the sub-arrays like [1,9]
and [1,3,6]
. It then loops through these sub-arrays and looks for matches
in player_moves_left
using the indexOf
method. If, for a given sub-array, all of its element are contained in player_moves_left
, we return true. If we get to the end of passing_moves
and the player is out of luck, we return false. This easily plugs into isGameOver
, where we return the final true/false verdict.
Again, here’s where isGameOver
is called:
// player just made a sum
if (board_array.indexOf(0) == -1){ // if board_array has no 0s, i.e. is all 1s
alert("Oh hey, You won!");
resetGame();
} else { //still playing
// re-roll dice.
computer_roll = rollTwoDie();
// with new dice roll, we can already figure out if the game is over
if (isGameOver(computer_roll, player_moves_remaining)){
alert("Computer's next roll is " + computer_roll + "...Game over :(");
resetGame();
} else { // if there exists a way to make the roll's sum...
sendMessage(randPraise() + "<br>New roll is " + computer_roll);
// reset for new roll, same game
possible_combinations = [];
player_total = 0;
}
}
Think that’s about it!
Side Note: I hope to write more programs that have a randPraise
function:
function randPraise(){
var praise = ["Awesome!", "Good job!", "Knew you could do it!", "Sweet!", "You got this!", "Again! Again!", "Keep it up!", "Keep going!", "Easy, right?"];
var rand = Math.floor(Math.random() * (praise.length));
return praise[rand];
}