Memory game in JavaScript
In this article, we’ll develop a memory game in JavaScript. The player will be shown a deck of cards laid out with their backs facing up, like this:
There are a total of 12 cards, with 6 unique photos, i.e., each unique photo appears on exactly two cards. When the user clicks on a card, the corresponding photo will be revealed momentarily:
If two cards that the player clicks on consecutively have the same photo, both cards will be turned over, otherwise, the cards will revert to being face down. The player wins once all cards are face up.
The HTML layout
We start by creating a directory for our project. We’ll create an index.html
file, an app.js
file, and a style.css
file.
We’ll start with a super simple layout for the page:
<!DOCTYPE HTML>
<HTML lang="en">
<Head>
<meta charset="utf-8">
<link rel="stylesheet" type="text/css" href="style.css"/>
<title>Memory game</title>
</Head>
<body>
<h2>Score: <span id='score'>0</span></h2>
<div id='grid'></div>
<script src="app.js"></script>
</body>
</HTML>
We have linked to a style.css
file, and an app.js
file. We’ve created a span
element to show the score, and a div
element to display the card deck. But, why do we not see the cards in the above HTML file? Since the cards don’t have a static arrangement and need to be shuffled randomly, we’ll add those programmatically.
Quick! Get the cards
Since our board has a total of 12 cards, we need 6 unique photographs. I went to pixabay and looked for puppy or dog photos of roughly square dimensions around 640 x 640 px. I placed them inside an images
subdirectory in the project’s folder.
The cards collection
Let’s start by declaring an array to hold the cards:
const uCardsArr = [
{
set:'1',
path:'images/cat-gb563d9095_640.png'
},
{
set:'2',
path:'images/dog-g924f54dfd_640.jpg'
},
{
set:'3',
path: 'images/dog-gc99455678_640.jpg'
},
{
set: '4',
path: 'images/dog-ge40dad750_640.jpg'
},
{
set:'5',
path:'images/pugs-g51e254b26_640.png'
},
{
set:'6',
path:'images/puppy-g07efd7216_640.png'
}
]
We’ve declared an array of objects, one for each unique card. Each card has a set
attribute and a path
attribute. In the path
attribute, I’ve saved the relative path to each unique image. The values in the set
attribute are arbitrarily assigned unique integers. Two cards with the same set
value will be identical cards. We could match two cards based on the path as well. But, I thought matching on an integer value is more efficient.
I’ll create another array to hold just the set
values, which I can shuffle. I’ll use the uCardsArr
array just for looking up the correct image to display. The definition is pretty simple. I’ve defined it using let
, so that I can modify it later:
let cardsArr = [1, 2, 3, 4, 5, 6]
Random shuffling the cards
There’s a neat little trick to random shuffling cards. It uses the Array.sort()
method. We can pass a custom comparison function to the sort
method. Behind the scenes, the sort
method compares two array elements at a time, each time making a decision whether they should be swapped or not. If you are sorting in ascending order, and compare element a
which appears earlier in the array, with b
which appears later, then a swap would be needed if b < a
. In this case, the sort
method expects the comparison function to return a negative value. If a
equals b
, it should return 0. Lastly, if a < b
, it should return a positive value.
This means that we could random shuffle an array using sort
, if our custom comparison function randomly returns positive, negative, or zero values. How do we do that?
Well the word “random” sounds familiar. We know Math.random()
. The problem, though, is that this function returns a random value between 0 and 1. No negative values. What do we do now?
Well, what happens if you subtract 0.5 from this random value? At one extreme, the random value is 0, thus 0.5–0 becomes -0.5. On the other end, the random value is 1, and 1–0.5 becomes 0.5. So, 0.5 — Math.random()
results in a random value between -0.5, and 0.5. Positive and negative values are equally likely.
Here’s the code:
cardsArr.sort(()=>0.5 - Math.random())
But, we needed two cards of each type. How about we duplicate the original array before shuffling it? Yea, that should work.
const cardsArr = [...cardsArr, ...cardsArr]
cardsArr.sort(()=>0.5 - Math.random())
Displaying the cards face down
Let’s first get access to the div
element which we defined for the card display. It had an ID of grid
. So, a document.getElementById(‘grid’)
will do. Recall that the div
element in HTML was empty. Once we have access to the div
, we need to insert img
elements, one for each of the cards. We’d like the HTML to look something like the following:
<div id="grid">
<img src="images/stripes-gf0306262b_640.jpg">
<img src="images/stripes-gf0306262b_640.jpg">
<img src="images/stripes-gf0306262b_640.jpg">
<img src="images/stripes-gf0306262b_640.jpg">
<img src="images/stripes-gf0306262b_640.jpg">
<img src="images/stripes-gf0306262b_640.jpg">
<img src="images/stripes-gf0306262b_640.jpg">
<img src="images/stripes-gf0306262b_640.jpg">
<img src="images/stripes-gf0306262b_640.jpg">
<img src="images/stripes-gf0306262b_640.jpg">
<img src="images/stripes-gf0306262b_640.jpg">
<img src="images/stripes-gf0306262b_640.jpg">
</div>
We can define the following function:
const gridDisplay = document.getElementById('grid')
function createBoard() {
cardsArr.forEach((c, index) => {
const card = document.createElement('img')
card.setAttribute('src', 'images/stripes-gf0306262b_640.jpg')
card.setAttribute('data-id', index)
card.setAttribute('data-set', c)
card.addEventListener('click', cardFlip)
gridDisplay.appendChild(card)
})
}
We iterate over the cardsArr
array that we created earlier. For each element in that array, we:
- Create an
img
HTML element - We set its
src
attributed to the path of the stripes image - We set a
data-id
attribute to indicate theindex
of the card. - We set a
data-set
attribute to indicate the set of cards to which this card belongs. When the user clicks on a card, from theimg
element, we want to be able to access the actual dog image to display. We’ll use this attribute to filter thecardsArr
array. - Since the user will interact with the cards grid by clicking on it, we’ve added a click event handler named
cardFlip
. We’ll define that in a minute. - We’ve added the
card
element togridDisplay
After having done that, once we call the function, the HTML markup will look something like this:
<div id="grid">
<img src="images/stripes-gf0306262b_640.jpg" data-id="0" data-set="6">
<img src="images/stripes-gf0306262b_640.jpg" data-id="1" data-set="1">
<img src="images/stripes-gf0306262b_640.jpg" data-id="2" data-set="2">
<img src="images/stripes-gf0306262b_640.jpg" data-id="3" data-set="3">
<img src="images/stripes-gf0306262b_640.jpg" data-id="4" data-set="4">
<img src="images/stripes-gf0306262b_640.jpg" data-id="5" data-set="3">
<img src="images/stripes-gf0306262b_640.jpg" data-id="6" data-set="5">
<img src="images/stripes-gf0306262b_640.jpg" data-id="7" data-set="6">
<img src="images/stripes-gf0306262b_640.jpg" data-id="8" data-set="1">
<img src="images/stripes-gf0306262b_640.jpg" data-id="9" data-set="5">
<img src="images/stripes-gf0306262b_640.jpg" data-id="10" data-set="2">
<img src="images/stripes-gf0306262b_640.jpg" data-id="11" data-set="4">
</div>
The card flip
How do flip the card to reveal the actual dog photo behind the stripes? We’ve already decided that we’ll have a click event handler on all img
elements named cardFlip
. Inside that function, we need to change the src
attribute of the img
element to the actual dog photo path. How do we get that path? We’ll filter the cardsArr
array using the data-id
attribute of the img
element.
function cardFlip() {
const id = this.getAttribute('data-id')
const set = this.getAttribute('data-set')
const match = uCardsArr.filter((c)=> c.set == set)
this.setAttribute('src', match[0].path)
}
The magical this
operator, in this function refers to the img
element on which the user clicked, because that’s the element on which this callback is invoked. So, we accessed the data-id
and data-set
attributes of the img
element clicked on. Then, we filtered the uCardsArr
array based on the set
attributed being equal to the data-set
specified in the img
element.
Recall that we had two of each photo in the cards grid. So, the filter
method will always return two array elements. They are both identical, so we can access either one. I went with the first one: match[0]
. We accessed the path
attribute of that object, and set that as the new value of the src
attribute of the img
element.
Applying styles to render a grid
Let’s now apply some CSS so that the cards are displayed in four rows with three cards each. Here’s the CSS:
#grid {
display: flex;
width: 400px;
height: 400px;
margin: 0 auto;
flex-wrap: wrap;
align-content: space-between;
justify-content: space-between;
}
img {
width: 128px;
height: 128px;
border: 1px solid black;
margin-bottom: 6px;
}
I am using CSS Flexbox, so display: flex;
. I set the grid’s width and height to 400 pixels. I did a margin: 0 auto;
so that the grid itself appears at the centre of the web page. To wrap every fourth card on to the next row, we’ll need flex-wrap: wrap;
. We’ve done align-content: space-between;
and justify-content: space-between;
to neatly spread the space between the cards.
But, why would every fourth card spill over to the next row? To ensure that, I set the width
of the img
elements to 128 pixels. Where did I get that magic number from?
Well, the width
of the div
is 400 pixels. There’ll be three images in a row, with equal space in between. Let’s say, we’ll have an 8 pixel space in between, that leaves us with 400–2x8 = 384 pixels. Divide that over three, and we get 128 pixels for each image.
The border in the img style is purely cosmetic. But, how do we achieve a vertical spacing between the cards in consecutive rows? We did that using border-bottom: 6px;
. Why didn’t we set it to 8 pixels? Well, I just played with the value until I got the vertical and horizontal spacing between cards that appeared roughly equal.
Card matching
So far, our game will respond to clicks by displaying the dog image. But, we want to check if two consecutively clicked cards match or not. For that, we’ll need to modify our click handler to keep track of two consecutively clicked cards. We’ll declare two variables to hold two consecutively clicked cards.
let card1 = null, card2 = null;
function cardFlip() {
const id = this.getAttribute('data-id')
const set = this.getAttribute('data-set')
const match = uCardsArr.filter((c)=> c.set == set)
this.setAttribute('src', match[0].path)
if (card1 === null) {
card1 = {id: id, set:set}
}
else {
card2 = {id: id, set:set}
checkMatch()
card1 = null
card2 = null
}
}
We’ll use a null
value to indicate that a card hasn’t been selected yet. In the beginning, no card has been selected, so both card1
and card2
are null
. We’ll set these variables consecutively: card1
first, then card2
. When the first card is pressed, both card1
and card2
are null
. But, we only checked if card1
is null
, and then set card1
to hold an object indicating the id
and set
of the card picked.
The next time the user clicks a card, card1
will not be null, so we’ll come down to the else
part. Since card1
is already set to the previously clicked card, we set card2
to hold an object for the second card. Then, we call a function checkMatch()
to check if the cards matched. We, then, set both card1
and card2
to null
to start the next round of the game.
Onto the checkMatch()
function. What do we need to do, here? Well, if the two cards match, we visually indicate somehow that these cards are out of the game, now. We would also need to remove the click handler from the two cards. If the two cards don’t match, we should flip them over.
function checkMatch() {
if (card1.set === card2.set) {
const cardsChosen = document.querySelectorAll(`img[data-set="${card1.set}"]`)
cardsChosen.forEach( c => {
c.style.opacity = 0.1;
c.removeEventListener('click', cardFlip)
})
}
else {
revertCards(card1.id, card2.id)
}
}
function revertCards(id1, id2) {
const cardSet1 = [ document.querySelectorAll(`img[data-id="${id1}"]`)]
cardSet1.forEach( c => {
c[0].setAttribute('src', wildCard)
})
const cardSet2 = [ document.querySelectorAll(`img[data-id="${id2}"]`)]
cardSet2.forEach( c => {
c[0].setAttribute('src', wildCard)
})
}
The if
condition will be true if the two cards match. We get hold of the two corresponding img
elements by using the selector query img[data-set=”${card1.set}”]
. That is, we are looking for all img
elements that have the data-set
attribute equal to the value in card1.set
. Of course, using card2.set
instead of card1.set
would also work.
We iterator over the img
elements selected and set their opacity to 0.1 so that the images appear dimmed out. We also don’t want the corresponding cards to trigger the click event handler anymore, so we call removeEventListener
with two arguments, one is the event (click
) and the other is the callback function (cardFlip
).
If the two cards don’t match, we come down to the else
part. We call a function revertCard
, which sets the src
attribute on the two selected cards back to the stripes image.
Glitch removal
At this point, there’s a problem with the game. When you click the first card, it stays face up for as long as you want. But you don’t get to see the second card that you click, because the revertCard
function gets called immediately and flips the card back over before you can see it. How do we fix this? We can insert a delay before the revertCard
function is called. Here’s the updated checkMatch
function:
function checkMatch() {
if (card1.set === card2.set) {
const cardsChosen = document.querySelectorAll(`img[data-set="${card1.set}"]`)
cardsChosen.forEach( c => {
c.style.opacity = 0.1;
c.removeEventListener('click', cardFlip)
})
}
else {
setTimeout(revertCards, 500, card1.id, card2.id)
}
}
The first argument to setTimeout
is the function to call, the second argument is the delay in milliseconds after which JavaScript should call it, and the remaining arguments are passed to that function in the given order. Don’t make the mistake of doing setTimeout(revertCards(card1.id, card2.id), 500)
. That doesn’t work. Why? Because the first argument is no longer a function handle for callback. It is a function call. That is, the function is called immediately.
The final touch
Finally, we want to display the current score. Also, we want to stop and display a message when a user has won. We’ll use a variable to keep track of the number of pair of cards successfully matched. Once that count reaches 6, the user has won.
Complete code
You can clone / download the entire project from this github repo. The individual source files are also given below.
Here’s the HTML file index.html
:
<!DOCTYPE HTML>
<HTML lang="en">
<Head>
<meta charset="utf-8">
<link rel="stylesheet" type="text/css" href="style.css"/>
<title>Memory game</title>
</Head>
<body>
<h2>Score: <span id='score'>0</span></h2>
<div id='grid'></div>
<script src="app.js"></script>
</body>
</HTML>
Here’s the CSS file style.css
:
#grid {
display: flex;
width: 400px;
height: 400px;
margin: 0 auto;
flex-wrap: wrap;
align-content: space-between;
justify-content: space-between;
}
img {
width: 128px;
height: 128px;
border: 1px solid black;
margin-bottom: 6px;
}
Here’s the JavaScript file app.js
:
const uCardsArr = [
{
set: 1,
path:'images/cat-gb563d9095_640.png'
},
{
set: 2,
path:'images/dog-g924f54dfd_640.jpg'
},
{
set: 3,
path: 'images/dog-gc99455678_640.jpg'
},
{
set: 4,
path: 'images/dog-ge40dad750_640.jpg'
},
{
set: 5,
path:'images/pugs-g51e254b26_640.png'
},
{
set: 6,
path:'images/puppy-g07efd7216_640.png'
}
]
let cardsArr = [1, 2, 3, 4, 5, 6]
cardsArr = [...cardsArr, ...cardsArr]
cardsArr.sort(()=>0.5 - Math.random())
const wildCard = 'images/stripes-gf0306262b_640.jpg'
let card1 = null, card2 = null
let cardsWon = 0
const gridDisplay = document.getElementById('grid')
const scoreDisplay = document.getElementById('score')
createBoard()
function createBoard() {
cardsArr.forEach((c, index) => {
const card = document.createElement('img')
card.setAttribute('src', 'images/stripes-gf0306262b_640.jpg')
card.setAttribute('data-id', index)
card.setAttribute('data-set', c)
card.addEventListener('click', cardFlip)
gridDisplay.appendChild(card)
})
}
function cardFlip() {
const id = this.getAttribute('data-id')
const set = this.getAttribute('data-set')
const match = uCardsArr.filter((c)=> c.set == set)
this.setAttribute('src', match[0].path)
if (card1 === null) {
card1 = {id: id, set:set}
}
else {
card2 = {id: id, set:set}
checkMatch()
scoreDisplay.innerHTML = cardsWon
if (cardsWon === 6) {
alert('You won')
}
card1 = null
card2 = null
}
}
function checkMatch() {
if (card1.set === card2.set) {
cardsWon = cardsWon + 1
const cardsChosen = document.querySelectorAll(`img[data-set="${card1.set}"]`)
cardsChosen.forEach( c => {
c.style.opacity = 0.1
c.removeEventListener('click', cardFlip)
})
}
else {
setTimeout(revertCards, 500, card1.id, card2.id)
}
}
function revertCards(id1, id2) {
const cardSet1 = [ document.querySelectorAll(`img[data-id="${id1}"]`)]
cardSet1.forEach( c => {
c[0].setAttribute('src', wildCard)
})
const cardSet2 = [ document.querySelectorAll(`img[data-id="${id2}"]`)]
cardSet2.forEach( c => {
c[0].setAttribute('src', wildCard)
})
}