🃏 1: Build an Accessible Memory Game

Project Goal Build A Memory Game with accessibility in mind!
What you’ll learn How to build a memory game while applying accessibility principles. You can play with the finished project
Tools you’ll need A modern browser like Chrome. An account at CodeSandbox.io. If you get lost, import the starting point for this chapter here. Instructions on how to do this are in Appendix 1. Or you can go to the Memory Game Code Sandbox.
Time needed to complete 4 hours

What You'll Build

sketchnote

Instructions

If you need to restart your project, clone this repo. To start this project, sign into your Code Sandbox account and fork this Memory Game Code Sandbox. You'll work in Code Sandbox to build out a memory game.

Getting Started

Add a Component

Let's start by adding a set of instructions to the game by editing Instructions.vue inside the views folder. We want to create instructions for our users so they can access them via a link.

instructions

Let's first create a section for the rules. Edit views/Instructions.vue by adding this text under the h2's closing tag:

Copy
<section>
    <h3>Rules</h3>
    <p>This is a memory game. The cards are shuffled and laid face down. The point of the game is to find all the matches; there are a total of 16 cards to match.</p>
    <ul class="list-instruct">
        <li>rules...</li>
    </ul>
</section>
1
2
3
4
5
6
7

Next create some data to loop inside the <li> to build the instructions. Edit the data block in the Instructions.vue file to add the instructions array inside the curly brackets:

Copy
data() {
    return {
        instructions: [
        "Click on a card to flip it over.",
        "If the two cards match, they will remain flipped.",
        "If they don't match, they will be turned over.",
        "Remember what was on each card and where it was.",
        "The game is over when all the cards have been matched.",
        "Every flipped card counts as a move."
        ]
    };
}
1
2
3
4
5
6
7
8
9
10
11
12

Now, add a v-for to the <li> in views/Instructions.vue to display the array of data. We can use the v-for directive to render a list of items based on an array. Learn about v-for.

Copy
<ul class="list-instruct">
    <li v-for="(instruction, index) in instructions" :key="index">{{instruction}}</li>
</ul>
1
2
3

You should see the rules listed. Let's do the same for the Score section. Add this <section> block after the closing </section> tag that lists the instructions:

Copy
<section>
    <h3>Scoring</h3>
    <ul class="list-instruct">
        <li v-for="(score, index) in scores" :key="index">
        <p class="star-category">{{score.value}} Stars</p>
        <span>{{score.description}}</span>
        </li>
    </ul>
</section>
1
2
3
4
5
6
7
8
9

Now create the data for the scores by adding this array after the closing bracket of the instructions array in the <script> block. Don't forget to add a comma after the close of the instructions array!

Copy
scores: [
    { value: 3, description: "30 moves or less" },
    { value: 2, description: "40 moves or less" },
    { value: 1, description: "50 moves or less" }
]
1
2
3
4
5

Finally add styles at the bottom of the document, after the closing </script> tag. Feel free to update any of these styles!

Copy
<style lang='scss'>
    .main-instruction {
      width: 80vw;
      max-width: 600px;
      margin: auto;
    }
    .list-instruct {
      list-style: none;
      padding: 0;
    }
    .star-category {
      font-weight: bold;
      margin-bottom: 0;
    }
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

Setting Up the Board

Controllers

Let's build out our game's home page!

We can start by building the section above the Memory game; the reset button, stars and number of moves. Switch to /views/Home.vue and overwrite the content in <div class="home"> in the template:

Copy
<div class="home">
    <main class="container">
        <section>
        <button class="restart buttonGray">
            <i class="fa fa-repeat"></i>
            <span class="reset">Reset</span>
        </button>
        <div>
            <ul class="stars">
            <li>
                <i class="fa fa-star"></i>
            </li>
            </ul>
            <p class="moves">Moves: 0</p>
        </div>
        </section>
    </main>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

💡

We are importing font-awesome in the index.html file inside the public folder. So we have access to all the icons from there. Check out the font-awesome icons!

Add styles to the bottom of this file:

Copy
<style lang="scss">
.gameController .stars {
  padding: 0px;
  display: inline-block;
  margin: 2em auto 0;
}

.star {
  list-style: none;
  display: inline-block;
  margin: 0 0.2em;
  font-size: 1.5em;
}
.moves {
  font-weight: bold;
}

.gameController .restart {
  float: right;
  cursor: pointer;
}

.container {
  display: flex;
  justify-content: center;
  align-items: center;
  flex-direction: column;
  margin-top: 1em;
}
</style>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

Where's the data? We will be using data stored in a Vuex store.

💡

Vuex is a state management pattern and library for Vue.js applications. It serves as a centralized store for all the components in an application, with rules ensuring that the state can only be mutated in a predictable fashion. Learn more about Vuex

Inside the store folder, open the index.js file. This holds all the content of our store which we will use to build our game. Let's start by adding some data that we can use to make Game Controller section dynamic!

Add some data to the state by overwriting the state object in /store/index.js:

Copy
state: {
    stars: 3,
    numMoves: 0
},
1
2
3
4

Go back to Home.vue and bring that data over by importing the store state and adding the state data you want to access in a computed property. Overwrite the entire <script> block in /views/Home.vue:

Copy
<script>
import { mapState } from "vuex";

export default {
  name: "home",
  computed: {
    ...mapState([
      "stars",
      "numMoves",
    ])
  }
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13

This will allow us to use the data from the store as you would use data from that component. Update the stars and numMoves in our content to reflect the data in our Vuex store by overwriting the <div> under the closing </button> tag in /views/Home.vue:

Copy
<div>
    <ul class="stars">
    <li v-for="(star, index) in stars" :key="index" class="star">
        <i :class="`${index} fa fa-star`"></i>
    </li>
    </ul>
    <p class="moves">Moves: {{numMoves}}</p>
</div>
1
2
3
4
5
6
7
8

By using a v-for for the stars, we can loop through the number of stars stored in our Vuex state. As people play the game and lose stars, it will automatically show the correct number. We will add that logic once the game is further along.

Game Board

It's time to start building out the game's interface!

Choose a couple of icons you'd like to use for this project (or use the ones listed below). You need a total of 8 icons; making 16 cards on the board. Font-awesome has a lot of icons to choose from!

Once you select the icons, add them to the state in the store index.js file as types by adding a comma after numMoves: 0 and adding this array:

Copy
types: ["car", "bug", "paw", "bomb", "gamepad", "diamond", "heart", "bell"]
1

Inside Home.vue, add types to the computed properties to import:

Copy
...mapState(["stars", "numMoves", "types"])
1

Let's display them in a new section which will hold a list of our cards. Add this section under the closing </section> tag in /views/Home.vue:

Copy
<section id="cards">
    <ul class="cards">
        <li v-for="(type, index) in types" :key="index">{{type}}</li>
    </ul>
</section>
1
2
3
4
5

We need to double the number of cards to make the card layout work. We also need to capture some metadata from each card:

  • Name
  • Icon
  • Is the card flipped?
  • Was this card a match?
  • Should we close the card?

Now we can start working on the game logic!

Game Logic

Let's start by creating a getter that grabs the types and generates card metadata.

Add a Getter to the store and import it in /views/Home.vue:

Store:

Copy
getters: {
    deck: (state) => {
      let deck = {
        cards: []
      };
      for (let index = 0; index < state.types.length; index++) {
        deck.cards.push({
          name: state.types[index],
          icon: "fa fa-" + state.types[index],
          flipped: false,
          match: false,
          close: false
        });
        deck.cards.push({
          name: state.types[index],
          icon: "fa fa-" + state.types[index],
          flipped: false,
          match: false,
          close: false
        });
      }
      return deck;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

Home.vue:

Copy
// Import mapGetters
import { mapState, mapGetters, mapActions } from "vuex";

// Add Getters to computed
computed: {
    ...mapState([
      "gameAnnounce",
      "win",
      "stars",
      "cardsFlipped",
      "numCardsFlipped",
      "numMoves",
      "cardsMatched",
      "types"
    ]),
    ...mapGetters(["deck"])
  },
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

And update the display content by overwriting the card ul in /views/Home.vue:

Copy
<ul class="cards">
    <li v-for="(card, index) in deck.cards" :key="index">{{card}}</li>
</ul>
1
2
3

You can see, that every card now has all this information displayed as a JSON object (we will fix the styles in a minute!):

  • name
  • icon
  • flipped
  • match
  • close

Let's use this data to populate a hidden image for each card unless it is flipped. We can use v-if to check if the card is flipped; let's show a question mark if it is. Otherwise, let's show the card's icon. To do this, we'll use conditional rendering.

In /views/Home.vue, overwrite the current <ul class="cards"> with this markup:

Copy
<ul class="cards">
    <li class="cardItem" v-for="(card, index) in deck.cards" :key="index">
    {{card.name}} <!-- placeholder to show what is inside each card -->
    <button
        :class="[ card.match ? 'card match' : card.flipped ? 'card show' : card.close ? 'card close' : 'card']"
    >
        <span v-if="!card.flipped">?</span>
        <div v-else :class="deck.cards[index].icon"></div>
    </button>
    </li>
</ul>
1
2
3
4
5
6
7
8
9
10
11

It's pretty ugly, so add some styles to the cards inside /views/Home.vue's <style> block:

Copy
// Cards
.cards {
  margin: 2em auto;
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  grid-gap: 0.5em;
  padding: 0;

  .cardItem {
    list-style: none;
  }

  .card {
    height: 90px;
    width: 90px;
    font-size: 4em;
    background: #061018 url(/img/fabric.5959b418.png);
    background-blend-mode: soft-light;
    border: 1px solid #acacac;
    color: #ffffff;
    border-radius: 8px;
    cursor: pointer;
    display: flex;
    justify-content: center;
    align-items: center;
    box-shadow: 5px 2px 20px 0 rgba(46, 61, 73, 0.5);
  }

  .show {
    font-size: 33px;
    background: #0b5891 url(/img/fabric.5959b418.png);
    cursor: default;
  }

  .match {
    cursor: default;
    background: #0e4b5a url(/img/fabric.5959b418.png);
    font-size: 33px;
    animation-name: match-animation;
    -webkit-animation-name: match-animation;
    animation-duration: 1000ms;
    -webkit-animation-duration: 1000ms;
    transform-origin: 70% 70%;
    animation-iteration-count: 1000ms;
    animation-timing-function: linear;
  }

  .close {
    cursor: default;
    animation-name: close;
    -webkit-animation-name: close;
    animation-duration: 1000ms;
    -webkit-animation-duration: 1000ms;
    -webkit-animation-fill-mode: both;
    animation-fill-mode: both;
    &:hover,
    &:focus {
      background-blend-mode: hard-light;
      color: #112c3e;
      border: 2px solid #112c3e;
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63

Looking better!

In App.vue, add more card styles so a nice contrasting background image shows up. Note, you might need to refresh your Code Sandbox to see these new styles.

Copy
.cards {
  .card {
    background: #061018 url("imgs/fabric.png");
  }
  .show {
    background: #0b5891 url("imgs/fabric.png");
  }

  .match {
    background: #0e4b5a url("imgs/fabric.png");
  }
}
1
2
3
4
5
6
7
8
9
10
11
12

Alright! We have our hidden cards! You can see that we have duplicates of each, but they should really be shuffled so they are not in the same order every time. Let's make that happen!

💡

If you are stuck at this point, feel free to start from here

In Home.vue, create a shuffle method which will go through all of the cards and change the order. Also, let's trigger that method on the created lifecycle hook.

Add a new methods object in the <script> block of /views/Home.vue under name: "home",:

Copy
methods: {
    shuffle(cards) {
      this.deck.cards = [];
      var currentIndex = cards.length,
        temporaryValue,
        randomIndex;
      // While there remain elements to shuffle...
      while (0 !== currentIndex) {
        // Pick a remaining element...
        randomIndex = Math.floor(Math.random() * currentIndex);
        currentIndex -= 1;
        // And swap it with the current element.
        temporaryValue = cards[currentIndex];
        cards[currentIndex] = cards[randomIndex];
        cards[randomIndex] = temporaryValue;
      }

      this.deck.cards = cards;
    }
},
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

Then, call this new shuffle method in a new lifecycle hook by adding a created() method under the methods() object you just pasted in:

Copy
created() {
    this.shuffle(this.deck.cards);
  },
1
2
3

You should see the order of the cards change as you refresh the page.

Adding Functionality

Now we can add a method that will allow users to flip cards over. Users also increase one move every time they flip over a card.

Let's start in Vuex. Add this flipping action by adding an Action and a Mutation to your Vuex store. Actions in Vuex respond to things happening and Mutations change state. In store/index.js, add a new method to the actions object by pasting the following between the curly brackets:

Copy
update_NumMoves({ commit }, { moves }) {
      commit("UPDATE_NUMMOVES", moves);
    },
1
2
3

Then, add a Mutation in a similar fashion:

Copy
UPDATE_NUMMOVES(state, payload) {
      state.numMoves = payload;
    },
1
2
3

Moving over to views/Home.vue, we need to import mapActions from Vuex. Working in /views/Home.vue, overwrite the import line in the <script> block:

Copy
import { mapState, mapActions } from "vuex";
1

Next, import update_NumMoves from the store by adding the following line under methods: {

Copy
...mapActions([
    "update_NumMoves",
]),
1
2
3

Now create a method to flip cards by adding this method under the line you just pasted into the methods object:

Copy
flipCard(card) {
    if (card.flipped) {
    return;
    } else {
    this.update_NumMoves({ moves: this.numMoves + 1 });
    card.flipped = true;
    }
},
1
2
3
4
5
6
7
8

Now let's make those cards flip when you click them (@click). Edit the card button markup in /views/Home.vue by overwriting it:

Copy
<button
    :class="[ card.match ? 'card match' : card.flipped ? 'card show' : card.close ? 'card close' : 'card']"
    @click="flipCard(card)"
    >
    <span v-if="!card.flipped">?</span>
    <div v-else :class="deck.cards[index].icon"></div>
</button>
1
2
3
4
5
6
7

You should see all your cards "flipping" over as you click on them. Notice that the CSS we added earlier is changing the styles as the class changes dynamically.

Let's make sure users can only open 2 cards at once. We will need to keep track of which and how many cards are open on the board.

Let's go to the Vuex store in /store/index.js to add a new value to keep track of which cards are open: cardsFlipped. Another to see how many cards are open: numCardsFlipped. Add these values into the state:

Copy
cardsFlipped: [],
numCardsFlipped: 0,
1
2

Now, we need a way to set the number of cards flipped; add new flipped cards, and clear cards that are flipped to start a new game.

Add some new mutations to that part of your Vuex store in /store/index.js (add commas after mutations if needed):

Copy
CLEAR_CARDSFLIPPED(state, payload) {
    state.cardsFlipped = payload;
},
UPDATE_CARDSFLIPPED(state, payload) {
    state.cardsFlipped.push(payload);
},
UPDATE_NUMCARDSFLIPPED(state, payload) {
    state.numCardsFlipped = payload;
},
1
2
3
4
5
6
7
8
9

Add some new actions to that area of store/index.js:

Copy
clear_CardsFlipped({ commit }, { cards }) {
    commit("CLEAR_CARDSFLIPPED", cards);
},
update_CardsFlipped({ commit }, { cards }) {
    commit("UPDATE_CARDSFLIPPED", cards);
},
update_NumCardsFlipped({ commit }, { num }) {
    commit("UPDATE_NUMCARDSFLIPPED", num);
},
1
2
3
4
5
6
7
8
9

Now add these new methods to the mapActions array in /views/Home.vue:

Copy
...mapActions([
    "update_NumMoves",
    "clear_CardsFlipped",
    "update_CardsFlipped",
    "update_NumCardsFlipped"
]),
1
2
3
4
5
6

Add these methods to your flipCard() method:

Copy
flipCard(card) {
    if (card.flipped) {
    return;
    } else {
    this.update_NumMoves({ moves: this.numMoves + 1 });
    }
    // only allow flips if there are < or = 2 flipped cards
    if (this.numCardsFlipped < 2) {
    card.flipped = true;
    this.update_NumCardsFlipped({ num: this.numCardsFlipped + 1 });
    this.update_CardsFlipped({ cards: card });
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

At this point, you are keeping track of the card flips.

💡

Remember that each card has these properties:

  • name
  • icon
  • flipped
  • match
  • close

We want to keep track of all matches to know when we win the game. So let's keep track of which cards have been matched in the store.

In store/index.js add this array to state:

Copy
cardsMatched: [],
1

Add to mutations:

Copy
CLEAR_CARDSMATCHED(state, payload) {
      state.cardsMatched = payload;
    },
UPDATE_CARDSMATCHED(state, payload) {
    state.cardsMatched.push(payload);
}
1
2
3
4
5
6

Add to actions:

Copy
clear_CardsMatched({ commit }, { cards }) {
      commit("CLEAR_CARDSMATCHED", cards);
    },
update_CardsMatched({ commit }, { cards }) {
    commit("UPDATE_CARDSMATCHED", cards);
}
1
2
3
4
5
6

Going back to /store/Home.vue, change the match to true when flipped cards are the same. Edit the mapState and mapActions methods:

Import State

Copy
...mapState([
    "stars",
    "numMoves",
    "types",
    "cardsFlipped",
    "numCardsFlipped",
    "cardsMatched"
]),
1
2
3
4
5
6
7
8

Import Action

Copy
...mapActions([
    "update_NumMoves",
    "clear_CardsFlipped",
    "update_CardsFlipped",
    "update_NumCardsFlipped",
    "clear_CardsMatched",
    "update_CardsMatched"
]),
1
2
3
4
5
6
7
8

We will handle this new functionality inside our flipCard() method. Under the line this.update_CardsFlipped({ cards: card });, add the following check:

Copy
// MATCH
if (
    this.numCardsFlipped === 2 &&
    this.cardsFlipped[0].name === this.cardsFlipped[1].name
    ) {
    for (let i = 0; i < this.deck.cards.length; i++) {
        if (this.deck.cards[i].name === this.cardsFlipped[0].name) {
        this.deck.cards[i].match = true;
        }
    }

    this.update_CardsMatched({ cards: this.cardsFlipped });
    this.clear_CardsFlipped({ cards: [] });
    this.update_NumCardsFlipped({ num: 0 });
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

You should be able to match cards and see them stay on the board permanently. However, we are not handling what happens when flipped cards don't match, let's do that next!

Continuing the flipCard() method, add this check after the closing bracket of the if statement you just added:

Copy
// NO MATCH
else if (
    this.numCardsFlipped === 2 &&
    this.cardsFlipped[0].name !== this.cardsFlipped[1].name
    ) {
    // Wait before closing mismatched card
    setTimeout(() => {
        for (let i = 0; i < this.deck.cards.length; i++) {
        if (this.deck.cards[i].flipped && !this.deck.cards[i].match) {
            this.deck.cards[i].flipped = false;
            this.deck.cards[i].close = true;
        }
        }

        this.clear_CardsFlipped({ cards: [] });
        this.update_NumCardsFlipped({ num: 0 });
        return;
    }, 500);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

You should be able to open all the cards. If they don't match, they will close when you continue playing. Matched cards will remain open.

Scoring

We still need to handle what happens when the game is won. And how do we want to handle the score?

In our store, let's add a way to keep track of winning the game and of how many stars we have left as we play.

In store/index.js, add to State:

Copy
win: false,
1

Add to Mutations:

Copy
UPDATE_WIN(state, payload) {
    state.win = payload;
},
UPDATE_STARS(state, payload) {
    state.stars = payload;
},
1
2
3
4
5
6

Add to Actions:

Copy
update_Win({ commit }, { win }) {
    commit("UPDATE_WIN", win);
},
update_Stars({ commit, dispatch }, { num }) {
    commit("UPDATE_STARS", num);
},
1
2
3
4
5
6

Now that those are available, let's import them in /views/Home.vue:

Copy
// In computed
...mapState([
    "stars",
    "numMoves",
    "types",
    "cardsFlipped",
    "numCardsFlipped",
    "cardsMatched",
    "win"
]),
// In methods
...mapActions([
    "update_NumMoves",
    "clear_CardsFlipped",
    "update_CardsFlipped",
    "update_NumCardsFlipped",
    "clear_CardsMatched",
    "update_CardsMatched",
    "update_Stars",
    "update_Win"
]),
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

Use these inside the flipCard() method after we increase the number of moves. Overwrite the top if statement:

Copy
if (card.flipped) {
    return;
    } else {
    this.update_NumMoves({ moves: this.numMoves + 1 });
    if (this.numMoves < 30) {
        this.update_Stars({ num: 3 });
    } else if (this.numMoves < 40) {
        this.update_Stars({ num: 2 });
    } else if (this.numMoves < 50) {
        this.update_Stars({ num: 1 });
    } else if (this.numMoves > 50) {
        this.update_Stars({ num: 0 });
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

Let's add some logic when we find a match. Overwrite the // MATCH logic:

Copy
// MATCH
if (
    this.numCardsFlipped === 2 &&
    this.cardsFlipped[0].name === this.cardsFlipped[1].name
    ) {
    for (let i = 0; i < this.deck.cards.length; i++) {
        if (this.deck.cards[i].name === this.cardsFlipped[0].name) {
        this.deck.cards[i].match = true;
        }
    }
    this.update_CardsMatched({ cards: this.cardsFlipped });
    this.clear_CardsFlipped({ cards: [] });
    this.update_NumCardsFlipped({ num: 0 });
    // if number of cards matched = number or cards, then win the game
    if (this.cardsMatched.length === this.deck.cards.length / 2) {
        this.update_Win({ win: true });
    }
  }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

Now you should see the stars updating as you play.

  • 3 stars = 30 moves or less
  • 2 stars = 40 moves or less
  • 1 star = 50 moves or less

Reset

Now, let's make sure we can start a new game and reset all of the game data when we press the reset button. Let's update our store first (/store/index.js):

Add to Actions:

Copy
async clearGame({ commit, dispatch }) {
    try {
    await dispatch("update_Win", { win: false });
    await dispatch("update_Stars", { num: 3 });
    await dispatch("clear_CardsFlipped", { cards: [] });
    await dispatch("update_NumCardsFlipped", { num: 0 });
    await dispatch("update_NumMoves", { moves: 0 });
    await dispatch("clear_CardsMatched", { cards: [] });
    await dispatch("update_GameAnnounce", { message: "" });
    } catch (error) {
    commit("ERROR", error);
    }
},
1
2
3
4
5
6
7
8
9
10
11
12
13

In /views/Home.vue, add clearGame to your mapActions:

Copy
...mapActions([
      "update_NumMoves",
      "clear_CardsFlipped",
      "update_CardsFlipped",
      "update_NumCardsFlipped",
      "clear_CardsMatched",
      "update_CardsMatched",
      "update_Stars",
      "update_Win",
      "clearGame"
    ]),
1
2
3
4
5
6
7
8
9
10
11

Now add a newGame() method inside /views/Home.vue:

Copy
newGame() {
    this.shuffle(this.deck.cards);

    for (let i = 0; i < this.deck.cards.length; i++) {
        this.deck.cards[i].flipped = false;
        this.deck.cards[i].close = false;
        this.deck.cards[i].match = false;
    }

    this.clearGame();
},
1
2
3
4
5
6
7
8
9
10
11

Add a newGame to the reset button's @click handler:

Copy
<button @click="newGame" class="restart buttonGray">
    <i class="fa fa-repeat"></i>
    <span class="reset">Reset</span>
</button>
1
2
3
4

Almost done!

Winning!

Let's add a congratulatory message when the game is finished. We will need the pre-built Winning component. Let's import it in views/Home.vue so we can use it a a child component:

Copy
// inside the script block
import Winning from "@/components/Winning.vue";
// under export default
components: {
    Winning
},
1
2
3
4
5
6

Let's show it once the game is won and hide the board. Add this snippet at the top of the views/Home.vue component, overwriting the current <main> line:

Copy
<Winning v-if="win" :newGame="newGame"></Winning>
<main v-else class="container">
1
2

Note: We are passing newGame as a prop from the child to the parent to be able to restart the game while the board is hidden

Inside /components/Winning.vue, add a congratulatory message and a button to restart the game by overwriting its current template markup:

Copy
<template>
  <div class="win">
    <div>
      <h2>Congratulations!</h2>
      <ul class="stars">
        <li v-for="(star, index) in stars" :key="index">
          <i :class="`${index} fa fa-star`"></i>
        </li>
      </ul>
      <p>You won the game with {{stars}} stars left!</p>
      <button class="buttonGray" @click="newGame()">Play again</button>
    </div>
  </div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14

Inside the script area in components/Winning.vue, bring over the newGame and stars by overwriting its current script:

Copy
<script>
import { mapState } from "vuex";

export default {
  name: "Winning",
  props: {
    newGame: { type: Function }
  },
  computed: {
    ...mapState(["stars"])
  }
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13

Don't forget to remove the card.name that shows the name of the card when you're ready to truly play the game!

Add UI finishing touches

In Home.vue add the following styles:

Copy
.buttonGray {
  background: #2e3d49;
  font-size: 1em;
  color: #ffffff;
  border-radius: 8px;
  cursor: pointer;
  justify-content: center;
  align-items: center;
  padding: 0.5em;
  box-shadow: 5px 2px 20px 0 rgba(46, 61, 73, 0.5);
  &:hover,
  &:focus {
    background: #0b5891;
  }
}

.reset {
  padding-left: 1em;
}

@media (min-width: 450px) {
  .cards {
    grid-gap: 1em;
    .card {
      height: 125px;
      width: 125px;
    }
  }
}
@media (min-width: 600px) {
  .cards {
    grid-template-columns: repeat(4, 1fr);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

💡

If you are stuck, feel free to start from here

Challenge

Let's add Assistive Technology support! You can announce route changes and inform screen readers about important actions. Here are some tasks you can tackle:

Update Metadata (titles)

In the router:

Copy
const routes = [
  {
    path: '/',
    name: 'Home',
    meta: {
      title: 'Memory Game - Home Page',
      metaTags: [
        {
          name: 'description',
          content: 'This is the home page for the accessible Memory Game using Vue.js.'
        }
      ]
    },
    component: Home
  },
  {
    path: '/Instructions',
    name: 'Instructions',
    meta: {
      title: 'Memory Game - Instructions Page',
      metaTags: [
        {
          name: 'description',
          content: 'This is the instructions page for the accessible Memory Game using Vue.js.'
        }
      ]
    },
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () => import(/* webpackChunkName: "about" */ '../views/Instructions.vue')
  }
]
const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})

router.beforeEach((to, from, next) => {
  const newMetaTitle = to.matched.slice().reverse().find(r => r.meta && r.meta.title);
  if (newMetaTitle) document.title = newMetaTitle.meta.title;
  next();
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

Doing this will provide a summary of a web page. Search engines display the meta description in search results.

Announce Route Update

In the store, add the following to:

State:

Copy
routeAnnouncement: '',
1

Mutations:

Copy
UPDATE_ROUTE_ANNOUNCEMENT(state, payload) {	
  state.routeAnnouncement = payload	
},
1
2
3

Actions:

Copy
update_routeAnnouncement({ commit }, { message }) {	
  commit('UPDATE_ROUTE_ANNOUNCEMENT', message)	
},
1
2
3

In App.vue, lets bring in the announcement with role="status".

The aria live region role of status has an implicit aria-live value of polite, which allows a user to be notified via AT (such as a screen reader) when status messages are added. Learn about role="status".

Copy
<p role="status">{{routeAnnouncement}}</p>
1
Copy
methods: {
  ...mapActions(["update_routeAnnouncement"]),
  announceRoute(message) {
    this.update_routeAnnouncement(message);
  }
}
1
2
3
4
5
6

In App.vue, lets keep track of when the route changes, and change the route announcement, as well as change the aria-current to active link. Learn about aria-current.

Copy
watch: {
  $route: function() {
    this.announceRoute({ message: this.$route.name + " page loaded" });
    
    this.$nextTick(function() {
      let navLinks = this.$refs.nav
      
      navLinks.querySelectorAll("[aria-current]")
        .forEach(current => {
          current.removeAttribute("aria-current");
        });

      navLinks.querySelectorAll(".router-link-exact-active")
        .forEach(current => {
          current.setAttribute("aria-current", "page");
        });
    });
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

By doing this, you are giving all users the ability to become aware of new content on the page and handling the focus accordingly.

Add Skip to Main Content link

Adding a Skip to Main Content link will allow users to focus on this when the new route is loaded. They can choose to navigate the site from the beginning or skip to main content.

Lets add this to the header in App.vue:

Copy
<ul class="skip-links">
  <li>
    <a href="#main" ref="skipLink">Skip to main content</a>
  </li>
</ul>
1
2
3
4
5

Add the style for this: notice that this link will remain hidden unless users tab to it or it is focused on reroute (we will add this next)

Copy

/* Skip to Main */
.skip-links {
  margin: 0;
  list-style: none;
  padding: 0;
  position: absolute;
  white-space: nowrap;

  a {
    background: #0e4b5a url(/img/fabric.5959b418.png);
    background-blend-mode: color-burn;
    display: block;
    opacity: 0;
    font-size: 1em;
    font-weight: bold;

    &:focus {
      opacity: 1;
      padding: 1em;
    }

    &:visited {
      color: white;
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

To add focus to this on route change, add the following to the watch inside App.vue:

Copy
this.$refs["skipLink"].focus();
1

Add id="main" along with aria-label inside the <main> tags in Home.vue and Instructions.vue:

Copy
<!-- Inside Home.vue -->
<main class="container" v-else id="main" tabindex="-1" aria-labelledby="gameTitle">
      <h2 id="gameTitle">Game Board</h2>

<!-- Inside Home.vue -->
<main class="main-instruction" id="main" tabindex="-1" aria-labelledby="instructionsTitle">
      <h2 id="instructionsTitle">Instructions</h2>
1
2
3
4
5
6
7

This will allow screen readers to pick up the content of the id as the accessible name for <main>.

Announce gameplay

Lets make sure that we are letting users know what is happening in the game as they play. They need to know the content of the cards they flip, whether they got a match or not, how many moves and stars they have left, how many matches are left in the game.

Try to do this one yourself without looking ahead. Only look at the code if you get stuck. Feel free to ask questions!!

Lets make some changes inside Home.vue. We will be adding aria-labels, aria-describedby, gameAnnounce, and update the announcement in the gameplay:

Copy
<template>
  <div class="home" aria-label="Memory Game Board">
    <p role="status">{{gameAnnounce}}</p>
    <Winning v-if="win" :newGame="newGame" :winningMessage="winningMessage"></Winning>
    <main class="container" v-else id="main" tabindex="-1" aria-labelledby="gameTitle">
      <h2 id="gameTitle">Game Board</h2>
      <section aria-label="Memory Game Controller" class="gameController">
        <button @click="newGame" class="restart buttonGray">
          <i class="fa fa-repeat"></i>
          <span class="reset">Reset</span>
        </button>
        <div>
          <ul class="stars" :aria-label="stars + ' stars left'">
            <li v-for="(star, index) in stars" :key="index" class="star">
              <i :class="`${index} fa fa-star`"></i>
            </li>
          </ul>
          <p class="moves">Moves: {{numMoves}}</p>
        </div>
      </section>

      <section aria-label="Memory Game Board" id="cards">
        <p id="gameUpdate">{{gameUpdate}}</p>
        <ul class="cards">
          <li
            v-for="(card, index) in this.deck.cards"
            :key="index"
            :aria-label="[ card.flipped ? card.name : '']"
            class="cardItem"
          >
            <button
              aria-describedby="gameUpdate"
              :aria-label="[ card.flipped ? card.name + ' flipped' : 'card ' + (index+1)]"
              :class="[ card.match ? 'card match' : card.flipped ? 'card show' : card.close ? 'card close' : 'card']"
              @click="flipCard(card)"
              :disabled="card.match"
            >
              <span v-if="!card.flipped">?</span>
              <div v-else :class="deck.cards[index].icon"></div>
            </button>
          </li>
        </ul>
      </section>
    </main>
  </div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

Update the script as well:

Copy
<script>
import Winning from "@/components/Winning.vue";
import { mapState, mapGetters, mapActions } from "vuex";

export default {
  name: "Home",
  components: {
    Winning
  },
  computed: {
    ...mapState([
      "gameAnnounce",
      "win",
      "stars",
      "cardsFlipped",
      "numCardsFlipped",
      "numMoves",
      "cardsMatched",
      "types"
    ]),
    ...mapGetters(["gameUpdate", "deck", "winningMessage"])
  },
  created() {
    this.shuffle(this.deck.cards);
  },
  methods: {
    ...mapActions([
      "clearGame",
      "updateDeck",
      "update_Win",
      "update_Stars",
      "clear_CardsFlipped",
      "update_CardsFlipped",
      "update_NumCardsFlipped",
      "update_NumMoves",
      "clear_CardsMatched",
      "update_CardsMatched",
      "update_GameAnnounce"
    ]),
    shuffle(cards) {
      this.deck.cards = [];
      var currentIndex = cards.length,
        temporaryValue,
        randomIndex;
      // While there remain elements to shuffle...
      while (0 !== currentIndex) {
        // Pick a remaining element...
        randomIndex = Math.floor(Math.random() * currentIndex);
        currentIndex -= 1;
        // And swap it with the current element.
        temporaryValue = cards[currentIndex];
        cards[currentIndex] = cards[randomIndex];
        cards[randomIndex] = temporaryValue;
      }

      this.deck.cards = cards;
    },
    newGame() {
      this.shuffle(this.deck.cards);

      for (let i = 0; i < this.deck.cards.length; i++) {
        this.deck.cards[i].flipped = false;
        this.deck.cards[i].close = false;
        this.deck.cards[i].match = false;
      }

      this.clearGame();
    },

    flipCard(card) {
      this.update_GameAnnounce({ message: "" });
      if (card.flipped) {
        this.update_GameAnnounce({
          message: "Card already flipped."
        });
        return;
      } else {
        this.update_NumMoves({ moves: this.numMoves + 1 });
        if (this.numMoves < 30) {
          this.update_Stars({ num: 3 });
        } else if (this.numMoves < 40) {
          this.update_Stars({ num: 2 });
        } else if (this.numMoves < 50) {
          this.update_Stars({ num: 1 });
        } else if (this.numMoves > 50) {
          this.update_Stars({ num: 0 });
        }
      }
      // only allow flips if there are < or = 2 flipped cards
      if (this.numCardsFlipped < 2) {
        if (this.numCardsFlipped < 1) {
          this.update_GameAnnounce({
            message: card.name + " flipped."
          });
        }
        card.flipped = true;
        this.update_NumCardsFlipped({ num: this.numCardsFlipped + 1 });
        this.update_CardsFlipped({ cards: card });
        // MATCH
        if (
          this.numCardsFlipped === 2 &&
          this.cardsFlipped[0].name == this.cardsFlipped[1].name
        ) {
          let matchesRemaining =
            this.deck.cards.length / 2 - this.cardsMatched.length - 1;
          for (let i = 0; i < this.deck.cards.length; i++) {
            if (this.deck.cards[i].name == this.cardsFlipped[0].name) {
              this.deck.cards[i].match = true;
            }
            this.update_GameAnnounce({
              message:
                card.name +
                " flipped. Match found! " +
                matchesRemaining +
                " matches left!"
            });
          }
          this.update_CardsMatched({ cards: this.cardsFlipped });
          this.clear_CardsFlipped({ cards: [] });
          this.update_NumCardsFlipped({ num: 0 });
          //if number of cards matched = number or cards, then win the game
          if (this.cardsMatched.length === this.deck.cards.length / 2) {
            this.update_Win({ win: true });
            this.update_GameAnnounce({
              message: this.winningMessage
            });
          }
        }
        // NO MATCH
        else if (
          this.numCardsFlipped === 2 &&
          this.cardsFlipped[0].name !== this.cardsFlipped[1].name
        ) {
          // Wait before closing mismatched card
          this.update_GameAnnounce({
            message: card.name + " flipped. No match."
          });
          setTimeout(() => {
            for (let i = 0; i < this.deck.cards.length; i++) {
              if (this.deck.cards[i].flipped && !this.deck.cards[i].match) {
                this.deck.cards[i].flipped = false;
                this.deck.cards[i].close = true;
              }
            }
            this.clear_CardsFlipped({ cards: [] });
            this.update_NumCardsFlipped({ num: 0 });
            return;
          }, 900);
        }
      }
    }
  }
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154

Update Winning to focus

When players win the game, we want to make sure the focus moves into the new content.

Lets create a directive to auto-focus an element when it comes into view.

Inside main.js:

Copy
Vue.directive('focus', {
  inserted: function (el) {
    el.focus()
  }
})
1
2
3
4
5

We will use this in Winning.vue:

Copy
<template>
  <div class="win">
    <div>
      <h2 id="congratulations" aria-labelledby="congratulations winningMsg" v-focus tabindex="-1">Congratulations!</h2>
      <ul class="stars" :aria-label="stars + ' stars left'">
        <li v-for="(star, index) in stars" :key="index">
          <i :class="`${index} fa fa-star`"></i>
        </li>
      </ul>
      <p id="winningMsg">{{winningMessage}}</p>
      <button class="buttonGray" @click="newGame()">Play again</button>
    </div>
  </div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14

And here is what the script should look like:

Copy
<script>
import { mapState } from "vuex";
export default {
  name: "Winning",
  props: {
    newGame: { type: Function },
    winningMessage: {type: String}
  },
  computed: {
    ...mapState([
      "stars"
    ])
  }
};
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

Your goal? Get a 100% Lighthouse score!

Resources

Project Starting Point

Project Checkpoint 1 - Added Cards

Project Checkpoint 2 - Added Functionality

Finished project

Author

Made with ❤️ by Maria Lamardo and edited by Jen Looper