Tic-Tac-Toe with Vue.js Jan 17 2018

Following on from our look at setting up the Vue.js and React frameworks in Rails 5.1, this post will look at building a small little component in Vue.js, strictly on the client-side (just HTML, JS and CSS, no backend Rails app!).

We're going to implement our own little version of the classic Tic-Tac-Toe game, called Tic-Tac-Vue.

Setup

We'll start by creating our structure - create a folder, an HTML page, a JS file, and a stylesheet.

mkdir tic-tac-vue cd tic-tac-vue touch index.html touch app.js touch app.css

Our HTML file is going to be very basic, let's run through our initial markup:

<html> <head> <title>Tic Tac Vue</title> <script src="http://unpkg.com/vue"></script> <link rel="stylesheet" href="./app.css" /> </head> <body> <script src="./app.js" type="text/javascript"></script> </body> </html>

This sets up our page, brings in Vue.js, our CSS and our own JS file (both currently empty).

Next up, we're going to add a root element for our app to live - this goes in our HTML body , above our script reference to our JS file:

<div id="app"></div>

And now we need the markup for our game grid itself, which we're going to put in a script tag with a special template type - this too goes in our body , and above our JS file script ref:

<script type="text/x-template" id="grid"> <div class="grid"> <div class="row" v-for="row in rows"> <div class="column" v-for="column in row"> {{column}} </div> </div> </div> </script>

Let's run through that so it's clear - our grid is made up of rows and columns, fairly simple. It'll be based on a 2D array representing that data, and so we're looping through our rows data, which is an array of arrays, and then looping through each individual array, which contains the values for our individual columns. We'll be expecting three rows, and three columns for Tic-Tac-Toe of course. The column values will either be blank (unplayed), or an x or an o if someone has played in that space. That's it!

Quick note: Why do we separate the markup like this? You could include that markup within the #app div directly when the app is so simple, however there are still a couple of downsides to this. The first is that you have to then be careful which markup you're using - the browser will process that as HTML within the DOM long before Vue gets to it, meaning that if you then start to use any markup specific to how Vue works (like referencing components with tags) that isn't HTML5 compliant, the browser could end up stripping it out, or doing odd things with it that'll affect how your app works. Additionally, most apps are going to have enough complexity to have at least one component (rather than defining the app as just a root Vue app), so it makes sense to define that as such from the beginning, as it's neater and clearer to follow, as well as more cleanly allowing for expansion as the need arises. We've separated our page container markup (to host our client-side app) from the app itself, which is comprised of our specific Vue markup template, and the JavaScript we'll be writing to implement the functionality itself.

With our HTML out of the way, let's format our grid so it looks like one - here is our CSS:

.row { clear: both; } .column { border: 1px solid #000; width: 50px; height: 35px; float: left; text-align: center; padding-top: 15px; }

Fairly simple - we're floating each column and having them on the same row, with each row clearing the float to move on to the next row - we setup a border for the grid itself, set a specific height and width, and make sure that if a text value is shown (x or o) it looks nice within the box for that space on the game grid.

Now we get to the fun part - our JavaScript! We have two tasks now to get our initial grid functional - we have to create and define our grid as a component, and we then have to create our Vue app with our grid component being rendered into our #app empty div above.

First, we define our grid component:

var Grid = Vue.component('grid', { template: '#grid' });

This is easy enough to follow - we define a component, named Grid, using the template with id grid, and store it as Grid for reference elsewhere.

We're going to need to set our initial data for the component though - the rows and columns that the template renders:

data: function() { return { rows: [ ['', '', ''], ['', '', ''], ['', '', ''] ] }; }

As we described above for our template, rows is a 2D array, an array of arrays, each array representing a row, and each value within representing a column in that row - and we're starting with them all blank and unplayed.

Next up, let's define our App:

var App = new Vue({ el: '#app', components: { Grid }, render: function(createElement) { return createElement(Grid); } });

Here we specify the element our app will reside in, so #app , and then the components we need to use - we just have one, our Grid component. Lastly, as we don't have any markup within our #app element to tell our app how and what to render, we do it with a custom render method, referencing our game grid component.

Open up index.html now in a browser and you'll see our grid! That's great, but it doesn't do a lot. Let's start making it so we can play the spaces, and get a game going!

Game on

First of all, we want the user to be able to select a space, and if it's empty, it'll place an x or an o in it, depending upon whose turn it is. Let's add a variable to our data to determine whose go it is, and which one is next:

data: function() { return { rows: [ ['', '', ''], ['', '', ''], ['', '', ''] ], next: 'x' }; },

So we've just added that next attribute with a default value of x to our component data.

Now we'll add a method to our component definition, that'll handle when a space is tapped:

methods: { tap: function(e) { if (e.target.innerText == '') { e.target.innerText = this.next; this.next = (this.next == 'x' ? 'o' : 'x'); } } }

So it only works if the space is free, sets the space, and then toggles which player is next. Reload our page, tap a space, and… nothing. That's because we have one final step - we need to hook up tapping on the game grid spaces to that method we just wrote!

Change the markup in our #grid component template, so it's like this:

<div class="column" v-for="column in row" v-on:click="tap"> {{column}} </div>

The v-on:click attribute is added, which adds an event handler for the click event to the component, pointing at the tap method. Try reloading again now, et voila! Tapping on spaces sets the next player character in the space, with no overwriting involved. Pretty neat, and not a lot of code either!

Data binding

We have our action in place, but we can't do much with spaces played as we're not updating our data based on the user input - we're just directly updating the UI. Instead, let's update our data array, and have that reflect to the UI, which is a much better way to handle things.

In our markup, we'll alter our Grid component template as follows:

<script type="text/x-template" id="grid"> <div class="grid"> <div class="row" v-for="(row, row_index) in rows"> <div class="column" v-for="(column, column_index) in row" v-on:click="tap" v-bind:data-row="row_index" v-bind:data-column="column_index"> {{column}} </div> </div> </div> </script>

Specifically, we're keeping track of the row and column index in our two-dimensional array looping, and then binding it as row and column data variables to reference when a space is played.

Over to our tap method now:

tap: function(e) { if (e.target.innerText == '') { let rows = this.rows; rows[e.target.attributes['data-row'].value][e.target.attributes['data-column'].value] = this.next; this.rows = rows.slice(0); this.next = (this.next == 'x' ? 'o' : 'x'); } }

Here we are grabbing the data rows, then updating the value of our column, using the data-row and data-column bindings present on the element tapped to find the right location in the array. The reason we do a slice(0) is because the way that the data binding works in Vue, the rows property in our data is reactive (meaning it is being watched for changes), but individual changes within the array won't trigger that watcher, and therefore a re-render. Instead we're using a local rows variable, and then effectively copying the whole array back into our data , which triggers our reaction and an update of the UI using our template (rather than us modifying the element directly).

So now we have our app data binding correctly, we can implement logic based on the current rows/columns data to check for win criteria!

Winner winner

To check for a winner, we need to check each row for three matching values, each column for three matching values, and the two diagonal paths for three matching values. If any of those are true, we have a winner! We can boil this down to the following:

checkWinner: function() { return ( this.checkValues(this.rows[0]) || this.checkValues(this.rows[1]) || this.checkValues(this.rows[2]) || this.checkValues([this.rows[0][0], this.rows[1][0], this.rows[2][0]]) || this.checkValues([this.rows[0][1], this.rows[1][1], this.rows[2][1]]) || this.checkValues([this.rows[0][2], this.rows[1][2], this.rows[2][2]]) || this.checkValues([this.rows[0][0], this.rows[1][1], this.rows[2][2]]) || this.checkValues([this.rows[0][2], this.rows[1][1], this.rows[2][0]])); }, checkValues: function(values) { return (values[0] != '' && values[1] != '' && values[2] != '' && (values[0] === values[1]) && (values[1] === values[2])); }

Add those methods to our component underneath our tap definition.

checkValues is easy enough - we expect an array of three values, we make sure all the values are not blank, and that they are the same. checkWinner then calls checkValues for all of our different possible value arrays - firstly checking each of the three rows (each of those is already an array with three elements), then the three columns, and then lastly, the two diagonals.

With our win logic in place, we now just need to hook it up. We'll add an additional property, finished , to our data:

data: function() { return { rows: [ ['', '', ''], ['', '', ''], ['', '', ''] ], next: 'x', finished: false }; },

We'll then reference that to make sure that you can't keep playing once we're finished - at the top of the tap method:

tap: function(e) { if (!this.finished && e.target.innerText == '') {

Lastly in terms of logic, we just need to amend the end of our tap method:

if (this.checkWinner()) { this.finished = true; } else { this.next = (this.next == 'x' ? 'o' : 'x'); }

So we only go to our next player if we've not found a winner - if we have found a winner, we mark the game as finished.

Playing the game now, we'll see that if one player wins, you can't continue playing. But some kind of feedback to indicate that the game is over (and to say who has won!) would be good. Easy! We can do that with a single additional line in our template - at the bottom within our .grid div, below the rows & columns, add:

<div class="status" v-if="finished">Finished! {{next}} wins!</div>

Add the following to app.css to make sure our message sits underneath the grid:

.status { clear: both; }

Now when someone wins and the game stops, you can see who has won!

Whose turn?

It'd be good if we could show whose turn it is next, in case the players lose track. We already have the data, and as you can probably guess from how we displayed the finish state, it's fairly easy to do - change our .status text div to the following:

<div class="status"> <span v-if="finished">Finished! {{next}} wins!</span> <span v-else>Next go: {{next}}</span> </div>

Here we're making use of v-if and v-else to make sure only one shows - either we're showing whose turn is next, or we show the finished/winner text as the game is over!

Replayability

We're almost there with our incredible take on Tic-Tac-Toe, but rather than having to refresh the page after every game to go again, wouldn't it be cool if we could just press a restart button instead?

We're going to move our code to determine the next player into its own method, so we can re-use it:

nextPlayer: function() { this.next = (this.next == 'x' ? 'o' : 'x'); },

Next up, we'll change the tap method to use that function - so for the winner check or next player logic:

if (this.checkWinner()) { this.finished = true; } else { this.nextPlayer(); }

Now, we'll implement our restart method:

restart: function(e) { this.rows = [ ['', '', ''], ['', '', ''], ['', '', ''] ]; this.finished = false; this.nextPlayer(); },

This just resets the rows, makes sure the game is no longer finished, and triggers the next player. Let's hook that up to something in the template now:

<div class="status"> <div v-if="finished"> <p>Finished! {{next}} wins!</p> <a v-on:click="restart">Restart</a> </div> <span v-else>Next go: {{next}}</span> </div>

Our restart link shows when the game is finished, and tapping it resets our game accordingly!

Stalemate

The last thing we need to handle, is the dreaded stalemate. When two world class Tic-Tac-Toe players are locked together, head-to-head, neither willing to budge an inch, we could end up with a tied game. What then? The game doesn't get marked as finished, but there are no more moves to make. The games cannot continue!

So let's add a check for that state, and handle it accordingly. We'll start by refactoring our checkValues method, so we can re-use the blank check within it for checking if all the spaces have been played:

checkValues: function(values) { return this.checkValuesPresent(values) && this.checkValuesMatch(values); }, checkValuesPresent: function(values) { return (values[0] != '' && values[1] != '' && values[2] != ''); }, checkValuesMatch: function(values) { return (values[0] === values[1]) && (values[1] === values[2]); }

As you can see, we've separated the two parts of checkValues into a check for whether the specified values are all present ( checkValuesPresent ) and whether the values all match ( checkValuesMatch ). Now we can use the checkValuesPresent check in our stalemate logic - add the following method:

checkStalemate: function() { return !this.finished && (this.checkValuesPresent(this.rows[0]) && this.checkValuesPresent(this.rows[1]) && this.checkValuesPresent(this.rows[2])); },

We're just making sure that the game isn't yet finished, and all of the rows have values present - then it must be a stalemate!

We'll add an additional property to track stalemates, so add the default value to our data:

next: 'x', finished: false, stalemate: false

And then we'll update our tap method to account for checking for stalemates:

if (this.checkWinner()) { this.finished = true; } else if (this.checkStalemate()) { this.stalemate = true; this.finished = true; } else { this.nextPlayer(); }

We have to check for a stalemate after a win, because there might be a winner on the very last space! If it is a stalemate, the game is finished, but also the stalemate property is set.

We can now use this on the frontend within the template to display something more relevant:

<div class="status"> <div v-if="finished"> <p v-if="stalemate">It's a draw! Stalemate!</p> <p v-else>Finished! {{next}} wins!</p> <a v-on:click="restart">Restart</a> </div> <span v-else>Next go: {{next}}</span> </div>

We're either displaying our draw statement for stalemates, or the winner text, and the restart button either way so a new game can start. The only thing left, is to make sure our stalemate flag is reset on restart:

restart: function(e) { this.rows = [ ['', '', ''], ['', '', ''], ['', '', ''] ]; this.finished = false; this.stalemate = false; this.nextPlayer(); },

Now win, lose or draw, players can continue playing!

Wrapping up

So our Tic-Tac-Toe game is pretty much feature complete, at least in terms of a basic game two people can play on a single computer. It saves on paper and drawing, and we've done it all with just a little HTML + CSS, and a bit of fun JavaScript, all thanks to Vue.js!

This is one way to build a Vue.js app, and is better suited for small, self-contained ideas like this. In a future post we'll look at going back to Webpack and using vue-cli , so that we can do things like having each component of our app (template HTML and JS logic) in its own .vue file, that's pre-processed accordingly to build the JS for our page. As projects increase in complexity, that helps to give them structure and keep them maintainable.

Check out the full code for this post at https://github.com/ejdraper/tic-tac-vue, and you can play the game itself below!

ejdraper • coding • vue • frontend • javascript • games

Please enable JavaScript to view the comments powered by Disqus.