Memory game in JavaScript

Muhammad Saqib Ilyas
11 min readJul 28, 2023

--

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:

The game’s starting layout

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:

Clicking on a card will reveal the photo on it
Clicking on a card will reveal the photo on it

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 the index 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 the img element, we want to be able to access the actual dog image to display. We’ll use this attribute to filter the cardsArr 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 to gridDisplay

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?

The calculation of space between images
The spread of div width among images

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)
})
}

--

--

Muhammad Saqib Ilyas

A computer science teacher by profession. I love teaching and learning programming. I like to write about frontend development, and coding interview preparation