loading...
Cover image for Let's Build A Game With Vue 3

Let's Build A Game With Vue 3

_ronini profile image Ronnie Villarini ・10 min read

This post first appeared on my blog

Today we're going to be building a tic-tac-toe clone like the one seen in the React docs. Except we're going to be using the Vue 3 Composition API.

🗣️You can check out the source code here or the live demo here.

If you're totally new to the Vue 3 composition API, I recommend you check out my post on that first.

This post will assume you have some basic understanding of the API, as well as Vue and JavaScript.

Set up


I'm going to be using Vite for quick setup as well as a lightening fast dev server. I definitely recommend checking it out! It uses Vue 3 by default, so we don't have any additional set up to worry about to get going.

To create the project run:

npm init vite-app tic-tac-toe
cd tic-tac-toe
npm i
npm run dev

Now you should be all set up and if you open your browser to localhost:3000 you should be looking at something like this:

Project scaffolding with Vite

Take a moment to look around the code if you want, otherwise lets get started!

The Board


First lets start creating the board. We will start by renaming the HelloWorld.vue component to Board.vue(Don't forget to change the imports!), then we will alter the template in App.vue along with replacing the boiler plate in the now named Board.vue.

// App.vue
import Board from './components/Board.vue';

export default {
    name: 'App',
    components: {
        Board,
    },
};

The board and related styles:

// Board.vue
<template>
<div class='board'>
    <span class='vertical-line-1'></span>
    <span class='vertical-line-2'></span>
    <Square />
</div>
</template>

<script>
import Square from './Square.vue'

export default {
  name: 'Board',
    components: {
        Square,
    }
}
</script>

<style scoped>
.board {
    position: relative;
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    grid-template-rows: repeat(3, 1fr);
}

.board::before, .board::after {
    background: linear-gradient(to right,  #41b883, #35495e)
}

.vertical-line-1, .vertical-line-2 {
    background: linear-gradient(to right,  #41b883, #35495e)
}

.board::before, .board::after {
    content: '';
    width: 100%;
    height: 5px;
    position: absolute;
    border-radius: 1rem;
}

.board::before {
    top: 33%;
}

.board::after {
    top: 66%;
}

.vertical-line-1, .vertical-line-2 {
    position: absolute;
    width: 100%;
    height: 5px;
    top: 50%;
    border-radius: 1rem;
    transform: translate(-50%, -50%) rotate(90deg);
}

.vertical-line-1 {
    left: 33%;
}

.vertical-line-2 {
    left: 66%;
}
</style>

Here we are just taking care of the basic markup and styling. Using a couple spans, and the board's before and after elements to take care of the lines that make up the game board. We're also registering a Square component, which will be responsible for rendering the actual buttons that the user clicks to place their X or O , respectively. Let's build that now.

The Squares


So before jumping into the code, let's think about what we expect from our Square component:

  1. It should receive a value - When the user clicks on the button, it should receive the value of the current player, and we'll display that value in our template.
  2. It should probably disable the button after it's been passed a value and/or if there is a winner.

That's ... really it! So let's build it out:

First let's take care of the template and the styles. We'll add a button with a disabled attribute which we will bind to winner || value, so that we can dynamically mark this button as disabled based on whether it has been clicked, or if the game is over. For accessibility reasons, we'll add a name attribute that we'll bind to a prop called label.

// Square.vue

<template>
    <button
        class='square'
        :name="label"
        :disabled='winner || value'
    >
        {{ value }}
    </button>
</template>

<style scoped>
.square {
    border: none;
    width: 10rem;
    height: 10rem;
    background: none;
    color: inherit;
    font-size: 3rem;
    font-weight: 700;
}

.square:hover {
    cursor: pointer;
}

.square:focus {
    outline: none;
    background: #41b88330;
}

.square:first-child,
.square:nth-child(2),
.square:nth-child(3) {
    border-top: none;
}

.square:nth-child(3),
.square:nth-child(6),
.square:last-child {
    border-right: none;
}

.square:nth-child(7),
.square:nth-child(8),
.square:last-child {
    border-bottom: none;
}

.square:first-child,
.square:nth-child(4),
.square:nth-child(7) {
    border-left: none;
}
</style>

Now let's add the JS! Since our component isn't in charge of maintaining any state, it'll be pretty lean. All we need is to declare our props, label, value, and winner.

<script>
export default {
    props: {
        label: String,
        value: {
            type: String,
            default: ' ',
        },
        winner: null,
    },
});
</script>

Cool! However, if you've been following along, you'll likely notice our app doesn't actually do anything yet. Let's add the game logic now!

Game Logic

Since we're using the composition API, we can o organize our code in related blocks, and then initiate all of that in the setup function. That might sound complicated, so lets take a look at the code and see what that means.

Let's plan everything out before we start writing code. (I'm just doing this to look good. I almost always code before thinking, which you're not supposed to do 🤫)

  1. We're going to keep track of the board, which we'll represent with an array. Each entry in the array will be one of the boxes on the board.
  2. The board will also need to keep track of who's turn it is, and what value (x or o) that player represents.
  3. When a square on the board is clicked, we should alter it's value in our array and notify the square of the change.

Cool! Now that we know how everything should work, lets get to coding.

import { ref } from 'vue'

setup() {
  const board = ref(Array(9).fill(null));
  const playerValue = ref('X');

  const markSquare = (i) => {
    const boardCopy = board.value.slice();
    boardCopy[i] = playerValue.value;
    board.value = boardCopy;
    playerValue.value === 'X' ? (playerValue.value = 'O') : (playerValue.value = 'X');
   };

   return {
     board,
     playerValue,
     markSquare,
   }
};

Inside our setup function we're initializing two variables, board and playerValue. We're using ref here, a new addition from the composition api, to wrap the variable in a reactive reference. Reactive Reference is an important distinction here, because you have to remember this is an object, and you have to add .value to actually access the value held within. This allows us to use Vue's reactivity system, so that anywhere these variables are referenced will be updated when it changes.

Next we declare a function called markSquare, that takes one parameter, i. This is common shorthand for index, and you'll see why we're expecting this value shortly. The function then creates a copy of the board, assigning it to the variable boardCopy.

💡 I'm using const here out of personal preference. I prefer to always use const unless I need to change the value, in which case I will change it to let. Since ref actually returns a reactive object we can access and change .value without throwing errors. For more info on how that works, I recommend checking this article over at ui.dev.

We'll now access the value located in our boardCopy array, located at the index that was passed to our function, and change the value from null (the initial value) to the value of whoever's turn it is. After that has been taken care of, we will just replace our current board with the copy. Before we return from our function, we're going to check to see who's turn it is and set the appropriate value so that the next player can begin their turn.

Last step here is the explicit return object from our setup function.

return {
    board,
    playerValue,
    markSquare,
};

Anything returned from the setup function becomes available in the template.

It's Important to note that passing a ref to the return object gets automagically unpacked by Vue. So you'll be able to reference it in the template at board instead of board.value!

Updating our board template

Now that we have our game logic in place, let's add the updates to the board so that we can start applying this logic and interacting with the game!

+ <h1 v-else>Next Up: {{ playerValue }}</h1>
 <div class='board'>
 <span class='vertical-line-1'></span>
 <span class='vertical-line-2'></span>
  <Square
+    v-for='(square, i) in board'
+    :key='`square-${i}`'
+    :label="`square-${i}`"
+    :value='square'
+    @click='markSquare(i)'
  />
 </div>

Alright! So we've added:

  • An h1 to show us who's turn it is.
  • A loop via v-for to create a Square component for each value in our Board array.
  • A :key attribute so that Vue can keep track of each instance of Square. This helps Vue keep track of what should and should not be updated.
  • We're now passing in the current index to the label prop. So our button label should read something like square-1 .
  • The :value of this square. We're getting this from our loop. Every square should have a value of null on initial render, and as the user clicks it will be updated with the current player's value.
  • An on click handler that calls our markSquare function, passing in the index of this square so that we update the correct value in our board array.

Now if you load up the game, you should be able to click through each tile, marking each with an x or an o! We're still missing a couple features though:

  1. We have no way of determining who won
  2. Nothing happens when the game ends, the user would probably like to have some sort of text in the UI that tells them who won, and that the game is over.

Calculating a winner and notifying the user.

First, we'll jump back down to our setup function to add the logic for calculating a winner. I borrowed the majority of the below function straight from the React Docs, with some modifications to Vue-ify it.

const calculateWinner = computed(() => {
    const lines = [
        [0, 1, 2],
        [3, 4, 5],
        [6, 7, 8],
        [0, 3, 6],
        [1, 4, 7],
        [2, 5, 8],
        [0, 4, 8],
        [2, 4, 6],
    ];

    for (let i = 0; i < lines.length; i++) {
        const [a, b, c] = lines[i];
        if (
            board.value[a] &&
            board.value[a] === board.value[b] &&
            board.value[a] === board.value[c]
        ) {
            return `${board.value[a]} Wins`;
        }
    }

    if (board.value.every(val => val)) return 'Tie!';

    return null;
});

So a few things are happening here:

  1. We're creating an array of arrays, lines, that houses all the possible winning combinations.
  2. We loop through each of the combos, checking to see if the current state of our board matches any of the possible conditions and declaring a winner if it does.
  3. If there is no winner, and the board is full, declare the game a tie.
  4. If the board isn't full and it isn't full yet, just return null.

Let's remember to declare this in our return object so we can use this computed function in the output.

return {
    board,
    playerValue,
    markSquare,
+ calculateWinner
}

Now we can reference this in our template to both disable all remaining squares on the board, and show a message to the user if a winner has been declared or if there is a tie.

+ <header v-if='calculateWinner' class='header'>
+    <h1>
+        {{ calculateWinner }}
+    </h1>
+ </header>
 <h1 v-else>Next Up: {{ playerValue }}</h1>
 <span ref='boardRef' class='confetti-origin'></span>
 <div class='board'>
 <span class='vertical-line-1'></span>
 <span class='vertical-line-2'></span>
  <Square
    v-for='(square, i) in board'
    :key='`square-${i}`'
    :label="`square-${i}`"
    :value='square'
    @click='markSquare(i)'
+    :winner='calculateWinner'
  />
 </div>

That's it! The game is complete! We can add a little polish though. How about a reset button so the user can play again, and we can add some confetti to celebrate the winner!

Reset and Celebrate

First let's add the reset button, as well as a the accompanying logic.

 <header v-if='calculateWinner' class='header'>
     <h1>
         {{ calculateWinner }}
     </h1>
+    <button class='reset' @click='reset'>Play Again</button>
 </header>
 <h1 v-else>Next Up: {{ playerValue }}</h1>
 <span ref='boardRef' class='confetti-origin'></span>
 <div class='board'>
 <span class='vertical-line-1'></span>
 <span class='vertical-line-2'></span>
   <Square
     v-for='(square, i) in board'
     :key='`square-${i}`'
     :label="`square-${i}`"
     :value='square'
     @click='markSquare(i)'
     :winner='calculateWinner'
   />
 </div>
+ const reset = () => {
        board.value = Array(9).fill(null)
        playerValue.value = 'X'
    }

    return {
        board,
        markSquare,
        playerValue,
+       reset
  };

Yayyy 🎉 Now our users can play again if they want to.

As for the confetti, we'll use a small library from npm, dom-confetti.

Install it via npm i dom-confetti, and then in Board.vue we can import it like so

import { confetti } from '../../node_modules/dom-confetti/src/main.js';

Note: If you're not using Vite, you could just use 'dom-confetti' as the path. I'm using Vite, which requires you to point directly to the file in this case.

The confetti function takes a DOM element as it's parameter, so we'll add an element to our template just for this

 <header v-if='calculateWinner' class='header'>
     <h1>
         {{ calculateWinner }}
     </h1>
    <button class='reset' @click='reset'>Play Again</button>
 </header>
 <h1 v-else>Next Up: {{ playerValue }}</h1>
+ <span ref='boardRef' class='confetti-origin'></span>
 <div class='board'>
 <span class='vertical-line-1'></span>
 <span class='vertical-line-2'></span>
   <Square
     v-for='(square, i) in board'
     :key='`square-${i}`'
     :label="`square-${i}`"
     :value='square'
     @click='markSquare(i)'
     :winner='calculateWinner'
   />
 </div>

Now in the setup function, we'll declare a ref that points at this DOM node, and then in the calculateWinner computed property, we'll call confett if there is a winner.

setup() {
+   const boardRef = ref(null)

    // ...

    const calculateWinner = computed(() => {
        const lines = [
            [0, 1, 2],
            [3, 4, 5],
            [6, 7, 8],
            [0, 3, 6],
            [1, 4, 7],
            [2, 5, 8],
            [0, 4, 8],
            [2, 4, 6],
        ]

        for (let i = 0; i < lines.length; i++) {
            const [a, b, c] = lines[i]
            if (
                board.value[a] &&
                board.value[a] === board.value[b] &&
                board.value[a] === board.value[c]
            ) {
+               confetti(boardRef)
                return `${board.value[a]} Wins`
            }
        }

        if(board.value.every(val => val)) return 'Tie!'

        return null
    })

    return {
        board,
+       boardRef,
        markSquare,
        playerValue,
        reset
    }
}

That's it! Open up your browser and try it out. (or if you haven't been following along, you can check out the live demo here.

If you want you can check out the repo, where you'll find a couple extras like TS, and refactoring into composition functions! I would've liked to include that here, but that was just out of the scope for this post. If there is enough interest, I'd be happy to make a follow up post!

Hope you had fun, and hopefully you learned a thing or two about using the composition api in project setting instead of just theory and small examples!

Be sure to follow me on all the socials for more content like this! Until next time 🖖🏻

Posted on by:

_ronini profile

Ronnie Villarini

@_ronini

Father, Developer, Married to JavaScript.

Discussion

pic
Editor guide