Building a multiplayer Tic Tac Toe game in Vue.js

This tutorial is for those of you who have a little familiarity with Vue.js and are looking for some examples of how you might use Ably Realtime in your own application.

Please note that we have avoided use of any advanced Vue.js techniques in the tutorial, and we do not claim to be using any specific best practices. In order to keep things simple and keep the focus on integration with Ably, we’ve cut numerous corners in pursuit of brevity. Please treat the example code as a starting point only. There are many ways it can be improved, including application structure, state management, unit testing and more.

We encourage you to treat the tutorial as a starting point toward understanding how to get started with the Ably client library in your own Vue application, and consult the Ably documentation and other tutorials as you build your knowledge and understanding of how the Ably Realtime platform works and everything it has to offer.

Introduction







We’re going to build a simple Tic Tac Toe application for two players. It’s going to have a lobby where players can create a game and see a list of links for games that need opponents. It will also have a basic server that issues an authentication token to authorize connections to Ably. This means that no matter who connects to a game, or reconnects after a disconnection, the system will be able to identify them properly and ensure that the messages they transmit are correlated with their unique identity.

The server authentication process will be very simple - a player’s client identifier will simply be their name. In a real application you’d authenticate players properly with unique credentials, but doing so is a general architectural concern, and so is outside the scope of this tutorial. Similarly, a robust implementation would have the server validating moves and preventing cheating. To keep things simple, our validation of game state messages is performed in the browser.

In the lobby, we’ll use presence events to expose games that are awaiting an opponent. New games will be published by having the game details "enter" the channel, and games that are no longer available will disappear from the list by "leaving" the channel. Anyone entering the lobby will subscribe for presence updates and be immediately shown a list of those games that are currently present in the channel and awaiting an opponent.

Upon creating a game or joining another player’s game, the player will be redirected to a custom URL for that game, where the custom token in the game’s URL will be associated with a channel created to capture the game’s activity. Each new message posted to the game channel will be processed and applied to produce an updated version of the local game state. Whenever the game state is updated, a simple event handler will dispatch the current game state object to the user interface, which will then update itself accordingly. If a player loses their connection and reconnects, or reloads the browser page, the game channel’s history of messages will be retrieved and replayed in order to rebuild the game back to its current state.

Note that the tutorial’s example project uses FontAwesome to render the O and X symbols on the game board. See FontAwesome’s free license for attribution requirements.

Getting Started

First, make sure you have your Ably account set up, with an application and API key in place.

Follow these instructions for setting up an API key if you haven’t done so before. In your application dashboard, go to the "Settings" tab and scroll down to the "Channel rules" section. Click the "Add new rule" button, and enter "tictactoe" in the "Namespace or Channel ID" field, then check the "Persisted" checkbox. This will enable history for any channel starting with tictactoe: . Hit "Save" and you’re done. Further details about setting up channel rules can be found here.

Next, let’s get the project environment and dependencies in place.

Though in many cases you might consider using the official Vue CLI to generate a full-featured build configuration and application skeleton, to do so here would result in a fairly complex file structure that would be more difficult to explain in this tutorial. Instead, we’ve gone for something simple and straightforward in order to avoid confusing the issue. If you know what you’re doing though, feel free to skip ahead to the sections that talk about integration with the Ably client.

Create a new folder for your project (mine is called tictactoe ), and inside it, create one folder for the web application, and one for the server application. In this tutorial, I’ll simply refer to these folders as client and server . As a starting point we’ll create a few extra folders and some blank text files, to be filled in shortly.

The initial file and folder structure inside your tictactoe folder should be as follows:

bc. /server package.json index.js /public index.html /client package.json webpack.config.js /src index.js /components app.vue

At this point, if you don’t have Node.js installed, visit the Node.js website and download and install the version labelled "Current". Wait until it has completely installed before proceeding further.

In your server folder, edit the package.json file and paste the following, then save and close it:

{ "name": "tictactoe-server", "version": "1.0.0", "main": "index.js", "scripts": { "start": "node index.js" } }

For reference on package.json files, see the NPM documentation for the package.json file format.

Next, in your console or terminal, navigate to the server folder. To install the server packages you’ll be using, type the following and press enter:

npm install express body-parser ably

Documentation about the Express.js and Express Body Parser packages we just installed: Express.js: http://expressjs.com/

Express Body Parser: https://github.com/expressjs/body-parser

We’ll now follow the same process as above, but for the client folder.

Paste the following into the client/package.json file:

{ "name": "tictactoe-client", "version": "1.0.0", "scripts": { "start": "webpack-dev-server --hot", "build": "webpack" } }

Next, use the terminal to install the packages for building and running the client. The build tools installation in the snippet below is split over two lines to fit on the page, but you should write it all on one line:

# Install the build tools npm install --save-dev webpack webpack-cli webpack-dev-server css-loader vue-loader vue-template-compiler vue-style-loader # Install the libraries required by the client app npm install vue vue-router nanoid ably

We’re using Webpack 4 to bundle up your code into something that a browser can load. Webpack configuration is beyond the scope of this tutorial - see the references below for further information about Webpack and the other packages we’ve installed.

Installing npm packages: https://docs.npmjs.com/cli/install

Webpack: https://webpack.js.org/guides/getting-started/

Vue: https://vuejs.org/v2/guide/installation.html

Vue loader for Webpack: https://github.com/vuejs/vue-loader

Ably client library: https://github.com/ably/ably-js

NanoID (for generating random game IDs): https://github.com/ai/nanoid

In your client folder, open the empty webpack.config.js you created earlier and paste the following into it:

const path = require('path'); const { VueLoaderPlugin } = require('vue-loader') const distPath = path.resolve(__dirname, '../server/public'); module.exports = { mode: 'development', output: { path: distPath }, resolve: { alias: { 'vue$': 'vue/dist/vue.esm.js' }, extensions: ['*', '.js', '.vue', '.json'] }, module: { rules: [ { test: /\.vue$/, loader: 'vue-loader' }, { test: /\.css$/, use: ['vue-style-loader', 'css-loader'] } ], }, devServer: { contentBase: distPath, port: 9000, historyApiFallback: true, proxy: { '/auth': 'http://localhost:3000' }, }, plugins: [ new VueLoaderPlugin() ] }

In addition to making sure Webpack understands .vue files, the above configuration will allow us to automatically serve and hot-update our code with a convenient local development web server, without having to manually refresh the page whenever we make a change.

In the server/public folder, create an index.html file and paste the following:

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Tic Tac Toe</title> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Alegreya+Sans:400,500,700"> <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.0.9/css/all.css" integrity="sha384-5SOiIsAziJl6AWe0HWRKTXlfcSHKmYV4RBF18PPJ173Kzn7jzMyFuTtk8JA7QQG1" crossorigin="anonymous"> </head> <body> <div id="app"></div> <script src="main.js"></script> </body> </html>

As we continue through the tutorial, you’ll need to use two terminal windows to run both the server application and the client. In production we only need to run the server, but during development we want to run the local dev server described earlier, to make development a little more convenient. It’ll proxy authorization requests to the actual server when required.

In the first terminal, to run the application server, you’ll want to navigate to the server folder and type npm start . Do the same in the other terminal window for the client folder. You can then navigate to http://localhost:9000 to run the web application. If you do this right now, you won’t see much, but be sure to do so as we start to write the code over the remainder of the tutorial. If you want to deploy the app to a server, you can build it properly, using npm run build . This will generate the client bundles directly into the server/public folder, ready for deployment with the server code and other public assets.

Implementing the server

Now that our build environment is set up, it’s time to write some code!

The entirety of our server’s code can be found below - paste it into server/index.js . Pay attention to the lines that make reference to the restClient instance. The Ably library provides both a Rest client type for one-time requests, and a Realtime client type for cases where subscriptions are required. We use the Rest client type in this case because we only need to generate a signed token request that will authorize the browser client to make further requests. This allows the API key to be kept secure on the server without exposing it to the browser, and it gives us a way to dictate permissions and capabilities in the future as needed. Take a look at the documentation on token authentication for complete details about how this works. Using the Rest client type allows us to avoid wasting our server resources holding open persistent connections to the Ably Realtime service. In the client app though, we’ll use the Ably Realtime client to monitor live activity in the lobby and in games.

server/index.js

Make sure to replace the YOUR_API_KEY_HERE string with your actual API key:

const path = require('path'); const express = require('express'); const bodyParser = require('body-parser'); const Ably = require('ably'); const restClient = new Ably.Rest({ key: 'YOUR_API_KEY_HERE' }); // See https://expressjs.com/ for help on using Express const app = express(); // Make the HTML, JavaScript and any other assets publicly accessible app.use(express.static('public')); // Auth requests are posted as JSON; the body parser is used to parse it app.use(bodyParser()); // Called by the Ably realtime client from the browser side. Normally we'd want // to do some form of authentication before issuing an authorization token to // the client, but to keep things simple we're just granting anonymous access // and using the user's name as the `clientId` value. You'll see where this // comes from a little later in the tutorial. app.post('/auth', (req, res) => { const clientId = req.body.name; // Here we are generating a token request locally, without needing to actually // call the Ably servers at all. We can do this because the Ably client here // on the server is initialized with our private API key, which means it can // be used to cryptographically sign the token request. Doing this means that // Ably's authentication servers can verify that the token request was // generated by us, thus "authorizing" the user to subscribe to channels and // publish messages or presence events, according to whatever capabilities and // constraints we bake into the token request when signing it. In this case, // there are no specific capabilities or constraints specified, so the user // will have a default authorization level, as specified by the settings // defined for the API key in your Ably account dashboard. restClient.auth.createTokenRequest({ clientId, }, (err, tokenRequest) => { console.log(`Authorization completed for client "${clientId}"`); if (err) { res.status(500); res.send(err); } else { res.send(tokenRequest); } }); }); // Ensure that direct requests for a game route are still served by the main index page app.get('/:gameId', (req, res) => { res.sendFile(path.resolve(__dirname, 'public', 'index.html')); }); // Use the hosting platform's preferred port, if available const port = process.env.PORT || 3000; app.listen(port); console.log(`TicTacToe server is now listening on port ${port}`);

The user interface

As mentioned earlier, we’re going to assume you have already got at least a basic level of familiarity with Vue.js. If you don’t, head over to the Vue.js website now and get yourself up to speed, as we’ll be glossing over the basics from here on in.

In your client folder, you should have a components folder. All .vue files referenced below should be placed here. Plain JavaScript ( .js ) files should be saved directly in the client/src folder.

app.vue

This is our main layout component and is referenced by our index.js file (further below), which bootstraps the application and the loads the view into the DOM. It doesn’t have any real functionality of its own; the main purpose of app.vue is to act as a container for the rest of the app. It ensures that the app title is displayed on every page, along with some common, application-wide styles, and it declares the router view, so that the app knows where to render the view for whichever route is currently active.

<template> <div id="app"> <h1>Tic Tac Toe</h1> <router-view :key="$route.path" /> </div> </template> <script> export default { name: 'App', }; </script> <style> body { margin: 20px; background-color: #001824; color: #f7f7f7; } body, button, input { font-family: 'Alegreya Sans', sans-serif; font-size: 24px; font-weight: 500; } h1 { margin: 40px 0; font-size: 48px; line-height: 32px; font-weight: 700; color: #FEC500; } #app { text-align: center; } </style>

Note that app.vue won’t do anything until we initialize the Vue engine though. We also need to set up the router.

index.js

This is a fairly standard entry point for a Vue application. You can see that we’re referencing the App component when we initialize the Vue object. We also provide a reference to the router. Vue will make sure that the router is made available to any component that needs to reference it.

import Vue from 'vue'; import App from './components/app.vue'; import router from './router'; // Prevent an unwanted developer message being displayed on startup // https://vuejs.org/v2/api/#productionTip Vue.config.productionTip = false; new Vue({ el: '#app', router, components: { App }, template: '<App/>', });

router.js

The app will have three main routes. Don’t try to run the application until you’ve created each of the route views referenced below, or you’ll get an error.

The Home page allows the player to enter their name, and then provides a link to proceed to the lobby.

page allows the player to enter their name, and then provides a link to proceed to the lobby. The Lobby page shows a list of games the player can join, and provides a button with which the player can host a game of their own.

page shows a list of games the player can join, and provides a button with which the player can host a game of their own. The Game route is used to render the user interface and live game state for a game that the player has hosted or joined.

import Vue from 'vue'; import Router from 'vue-router'; import Home from './components/home'; import Lobby from './components/lobby'; import Game from './components/game'; Vue.use(Router); export default new Router({ mode: 'history', // use real URLs, not "hashbang" URLs routes: [ { path: '/', name: 'Home', component: Home, }, { path: '/lobby', name: 'Lobby', component: Lobby, }, { path: '/:id', name: 'Game', component: Game, }, ], });

home.vue

This is the view of the home page. The home page’s purpose is to prompt the user to enter their name before they proceed to the lobby, where they’ll be able to join a game or create a game of their own.

<template> <div class="home"> <set-name :name="name" @set-name="onSetName" @accept-name="onAcceptName" /> <large-button @click="onAcceptName" text="Enter Lobby" :is-disabled="!name" /> </div> </template> <script> import LargeButton from './large-button'; import SetName from './set-name'; // The user's name is persisted in localStorage to avoid having to type it in // again every time the user loads the app. import { storeUserName, retrieveUserName } from '../state'; export default { name: 'home', data() { return { name: retrieveUserName() } }, components: { SetName, LargeButton, }, methods: { onSetName(name) { // We use this in the template (above) to disable the "Enter Lobby" button // while the user's name has a length of zero. this.preventCreate = name.length === 0; this.name = name; }, onAcceptName() { // Persist to localStorage, then redirect to the lobby page storeUserName(this.name); this.$router.push(`/lobby`); } } }; </script>

large-button.vue

The LargeButton component is a large-styled button for use when entering or returning to the lobby, and for creating games. It simply emits an event to its parent component when clicked. It can also be disabled when the parent component is busy, or not in a valid state. The button text has a default value of "Submit", but ideally should be specified by the parent component.

<template> <div :class="{ 'large-button': true }"> <button @click="click" :disabled="isDisabled">{{ text }}</button> </div> </template> <script> export default { name: 'large-button', props: { text: { type: String, default: 'Submit' }, isDisabled: { type: Boolean, default: false } }, methods: { click() { // Emit a "click" event to the parent component this.$emit('click'); } } }; </script> <style scoped> button { border: none; border-radius: 5px; padding: 10px 30px; font-weight: 500; outline: none; color: #f7f7f7; background-color: #73021B; } .large-button { margin-top: 40px; } .large-button button[disabled] { color: #27627F; background-color: #003D50; opacity: 0.5; } .large-button button:not([disabled]) { cursor: pointer; } .large-button button:not([disabled]):hover { color: #FEC500; } .large-button button:not([disabled]):active { color: #001824; background-color: #FEC500; } </style>

set-name.vue

This is a simple input control used to allow the user to enter their name before they proceed to the lobby.

<template> <div class="set-name"> <input type="text" ref="input" placeholder="Enter your name here" :value="name" @input="onInput" @keydown="onKeyDown" /> </div> </template> <script> export default { name: 'set-name', props: ['name'], methods: { onInput({ target: { value } }) { // Whenever the user enters something into the box, the value is emitted // to the parent component (see home.vue) to allow it to determine the // validity of the input value, which affects whether or not the user can // save their name and enter the lobby. this.$emit('set-name', value.trim()); }, onKeyDown({ keyCode }) { // If the user presses the ENTER key, tell the parent component, as it // should be treated as though they clicked the associated submit button. if (keyCode === 13) { this.$emit('accept-name'); } } }, mounted() { // Put the initial focus on the input field, to allow the user to type in // their name without having to click it manually. this.$refs.input.focus(); } }; </script> <style scoped> .set-name { display: block; } input { border: none; outline: none; text-align: center; line-height: 36px; color: #f7f7f7; background-color: rgba(0, 0, 0, 0.2); } input::placeholder { color: #e7e7e7; } input:focus::placeholder { color: rgba(255, 255, 255, 0.15); } </style>

lobby.vue

The lobby is the second of our three route views. In the lobby we subscribe to Ably "presence" events, which we’ll use to keep track of any games that are waiting for an opponent to join. Any games that are present in the channel will be displayed as a list of clickable links, each with a randomly-generated URL that matches the Game view (see game.vue , further below).

The enterLobby function subscribes the user to presence events in the "tictactoe:lobby" channel. Though there may be many players in the lobby, we only use presence to show which games are awaiting opponents. If you were to improve the functionality of the app and make it possible for players to see which other players are currently in the lobby, then presence events could be used for that purpose also. Doing so would actually be a more common use case for presence events, but how you use them in your own app is entirely up to you. The way we’re using them here is demonstrative of the fact that they can be used in whatever way is appropriate for your application’s architecture.

In the code below, you’ll see we’re making reference to a clientId value. This would normally be provided by the server to uniquely identify a given user, even if they change their name, email address or other details. In a real application, you’d have the user authenticate properly using some form of login credentials, and the clientId value would then be provided upon successful authentication. To keep things simple, we’re just using the user’s name for their client id, which means if two players enter the same name, they’ll be treated by the application as being the same user. We still authenticate with the server though

<template> <div class="lobby"> <div class="games-list"> <ul v-if="games.length > 0"> <li v-for="game in games"><a :href="`/${game.gameId}`">Enter game versus {{ game.name }}</a></li> </ul> <div v-if="games.length === 0">There are no other games to join at the moment.</div> </div> <large-button @click="onCreateGame" text="Create Game" :is-disabled="false" /> </div> </template> <script> import LargeButton from './large-button'; import { enterLobby } from '../state'; // A handy npm package that lets us generate random a id when creating a game. // The game id is appended to the URL, and matches the last route. import generate from 'nanoid/generate'; export default { name: 'lobby', data() { return { // A list of games currently awaiting an opponent games: [] } }, components: { LargeButton }, methods: { // When the user clicks the "Create Game" button, we'll generate a random // game id, then use it in the URL when redirecting to the game route. onCreateGame() { const gameId = generate('0123456789abcdefghijklmnopqrstuvwxyz', 8); this.$router.push(`/${gameId}`); }, // In the `mounted` lifecycle event below, we'll bind the `onGamePending` // method and use it as a callback to be called whenever a presence event // tells us that a game has been created and is awaiting an opponent, or // that a game now has an opponent and is no longer available to join. // If the `game` parameter has a value, it means the game is now "present" // in the channel and waiting for an opponent. If the game argument is // omitted, it means the game with specified clientId has left the channel // (no longer "present"), and is thus no longer available to join. onGamePending(clientId, game) { const index = this.games.findIndex(g => g.clientId === clientId); if (!game && index !== -1) { this.games.splice(index, 1); } else if (game && index === -1) { this.games.push({ clientId, ...game }); } } }, mounted() { // The user subscribes to presence events as soon as the page has loaded enterLobby(this.onGamePending.bind(this)); } }; </script> <style scoped> .games-list { padding: 20px; color: #27627F; background-color: #011018; } ul { margin: 0; padding: 0; display: inline-flex; flex-direction: column; align-items: flex-start; list-style-type: none; } li a { display: block; color: #FEC500; } </style>

state.js

Before we look at the code for displaying the game board, let’s get the game logic and networking code in place.

The state.js file contains all of the functionality for interfacing with the Ably servers, and for translating messages to and from a format that the Vue components will understand. The implementation of "state" here is quite simple, as our focus is on helping you get your feet wet with Ably’s feature set from within a Vue.js application. In a larger application you might prefer to use more robust state management techniques. Take a look at the State Management page on the Vue.js website for more information. You might like to think about how you could improve the tutorial application’s architecture with the Vuex library suggested in the aforementioned state management link.

To get started, you may recall mention of localStorage (earlier in the tutorial) for persisting the user’s name locally so that they only have to type it in once, rather than every time they load the website. These are the first two functions exported in the state.js file.

import * as Ably from 'ably'; export function retrieveUserName() { return localStorage.getItem('tictactoe:name'); } export function storeUserName(name) { if (!name) { debugger; } localStorage.setItem('tictactoe:name', name); }

The next function, enterLobby() was used in the lobby.vue file. It takes a callback function that will be called whenever a presence event tells us that a game is available and awaiting an opponent, or is no longer available due to an opponent having joined.

We can’t write the enterLobby() function without initializing the Ably client first though. Here’s how you would do that:

const name = retrieveUserName(); client = new Ably.Realtime({ authUrl: '/auth', authMethod: 'POST', authParams: { name } });

Note the lack of an API key during initialization. We can omit the API key and instead tell the client how to request an authorization token. If you have skimmed through the tutorial, return to the server/index.js section and review the details about the authorization process. The tictactoe server will sign a token request using our secret API key, and the Ably client will then use it when connecting to the Ably platform servers.

Above, the authUrl parameter points the client at the tictactoe server authorization route. authMethod ensures that a POST request will be made, and authParams specifies the content of the request body that is posted to the server. As discussed earlier, to keep the tutorial simple, we’re treating the user’s name as their unique client ID, and forgoing any special authentication process, which is why only the name property is specified in the authParams property above.

There is no further effort required to connect to Ably. All authentication, authorization and connection management is handled for you automatically, which means you can get on with actually implementing the application logic.

Let’s wrap the client initialization code in a function, and add an additional function to get the lobby channel whenever we need it. Note that the Ably client persists active channel references internally, which means that, at least in a simple application such as this one, there is no need to explicitly keep track of the channel reference between calls. We’ll also add a function to get the client ID, though as discussed above, it’ll just be returning the user’s name for now. If you improve the application with a proper authentication and authorization system, you’ll want to update the getClientId function referenced in the code below.

const getClient = (() => { let client; return () => { if (!client) { const name = retrieveUserName(); client = new Ably.Realtime({ authUrl: '/auth', authMethod: 'POST', authParams: { name } }); } return client; }; })(); function getLobby(client) { return client.channels.get('tictactoe:lobby'); } function getClientId() { return retrieveUserName(); }

Now we’ll implement the enterLobby function. It has one parameter, onGamePending , which is a callback function provided in the lobby.vue file that we implemented earlier. See the Ably documentation on Presence for full details on how presence works. In the code below, we subscribe to the lobby’s presence channel. The documentation for the presence.subscribe() method explains this in more detail.

export function enterLobby(onGamePending) { const client = getClient(); const lobbyChannel = getLobby(client); const userClientId = getClientId(); // In the lobby, we use presence only for players who have hosted a game and // are waiting for an opponent to join. When an opponent joins a game, the // host will leave the lobby. lobbyChannel.presence.subscribe(({ action, clientId, data }) => { // Ignore the user's own presence events if (userClientId === clientId) { return; } switch (action) { case 'enter': case 'present': onGamePending(clientId, data); break; case 'leave': // Omitting the second argument indicates that the game now has an // opponent and is no longer available for others to join. onGamePending(clientId); break; } }); }

Presence is not entirely automatic - it is up to developers to write the code to specify when a user enters or leaves a channel. This is important because there are many reasons you may not want a user’s presence to be publicised to other members, such as when they’re anonymous and not logged in, or if they have set their status as "invisible". Note that a user will automatically leave the presence channel though if their client becomes disconnected. When this happens, Ably will automatically emit a leave event to the presence channel to let other subscribers know that the disconnected user is no longer present.

Finally, we need to implement management of the game state. The game board will be represented as an array of nine integers, with the first row occupying the first three elements, and so forth. A value of 0 will be used for unassigned board positions, 1 will be used for moves made by the game host, and 2 will represent moves made by the opponent that joined the game.

Below is the code for checking for a win condition. No special algorithm is required, as the board array is so small that we can simply use brute force to check all of the potential winning sequences on the board.

function checkForWinCondition(board, latestPosition) { const value = board[latestPosition]; const checkPositions = (a, b, c) => board[a] === value && board[b] === value && board[c] === value && [a, b, c]; return checkPositions(0, 1, 2) || checkPositions(3, 4, 5) || checkPositions(6, 7, 8) || checkPositions(0, 3, 6) || checkPositions(1, 4, 7) || checkPositions(2, 5, 8) || checkPositions(0, 4, 8) || checkPositions(2, 4, 6); }

The remaining state.js code is included below in full, though generously-decorated with comments. Note the use of Ably’s history feature. If a player loses their connection temporarily, or reloads the page, we can simply read back through the game channel’s message history to rebuild state.

The first function, enterGame() , establishes the game channel subscription, reads any messages in the channel’s existing history, and initializes the game’s basic state. The function is exported and called by the game view, which we’ll cover in the next section. The game view calls enterGame() , passing the game ID and a callback function that we should call whenever the game state changes. The game view can then re-render itself to reflect the updated game state.

The second function, createGameMessageHandler() , does the actual work of reading messages received from the existing channel history and received subsequently via the channel subscription. Each received message is processed, verified and applied to the game’s state, which is then passed to the view, via the onGameStateUpdated handler that was originally passed to the enterGame() function.

export function enterGame(gameId, onGameStateUpdated) { const client = getClient(); const lobbyChannel = getLobby(client); // If we've just arrived from the lobby, we don't need to watch for available games anymore lobbyChannel.presence.unsubscribe(); const gameChannel = client.channels.get(`tictactoe:game:${gameId}`); const clientId = getClientId(); // We'll start with a basic state object and populate it as messages arrive on the game channel const gameState = { clientId }; // We'll use `updateGameState` to process channel messages const updateGameState = createGameMessageHandler(gameState, onGameStateUpdated); // Get the game channel history before proceeding, either because we're joining the game as a // challenger, or because the page was reloaded gameChannel.history({ limit: 1000 }, (error, page) => { if (error) { // In a real application you'd handle the error properly console.error(error); return; } // To save time we'll assume that the history is less than the maximum we specified. In a real // application, you'd use the additional `PaginatedResult` methods provided in the `page` object // in order to read back through the channel's history, one page at a time. // See https://www.ably.io/documentation/realtime/history#paginated-result for details. // The messages are in reverse chronological order, so copy the array and reverse it const messages = [...page.items]; messages.reverse(); // If the history is empty, it means this is a newly-created game. Select which player will be // going first. We'll toggle this value whenever the current player makes a move. if (messages.length === 0) { gameChannel.publish('create', { host: clientId, currentPlayer: Math.random() < 0.5 ? 'host' : 'opponent' }); } else { // Replay all the historical messages in order to bring the game back to the correct state for (let msg of messages) { updateGameState(msg); } } // An undefined host means the first message hasn't even arrived yet const isHost = !gameState.host || gameState.host === clientId; // We haven't implemented code to allow changes to presence data, or any effects relating to a // player leaving the channel, so really we only need to handle 'enter' and 'present' events gameChannel.presence.subscribe(['enter', 'present'], msg => { if (msg.clientId === clientId) { return; } // Store the other player's name so it can be displayed in the UI gameState.otherPlayerName = msg.data.name; onGameStateUpdated(gameState); // If we're the host and there is no opponent yet, the player who has just joined the game // channel will become the opponent. We'll publish this to the game channel so that we know // who the other player is, even if they disconnect and reconnect later. if (isHost && !gameState.opponent) { gameChannel.publish('opponent', { clientId: msg.clientId }); // gameState.opponent = msg.clientId; // Let the host UI know that the opponent has joined and that the game can begin // onGameStateUpdated(gameState); } }); // Enter the channel and make our name available to the other player gameChannel.presence.enter({ name: retrieveUserName() }); // Monitor game state messages as the game progresses gameChannel.subscribe(updateGameState); // If we are the host and there is no opponent, use presence to advertise the game in the lobby if (isHost && !gameState.opponent) { // Use presence in the lobby to expose the game details lobbyChannel.presence.enter({ gameId, name: retrieveUserName() }); } }); // We return this function to the game view to allow it to tell us when the player makes a move return function selectBoardPosition(position) { gameChannel.publish('move', { position }); }; } function createGameMessageHandler(gameState, onGameStateUpdated) { return function updateGameState(message) { const { clientId, data } = message; switch (message.name) { case 'create': if (gameState.currentPlayer) { console.warn('The game has already been initialized; disregarding "create" message'); return; } gameState.host = clientId; gameState.status = 'waiting'; gameState.board = [0, 0, 0, 0, 0, 0, 0, 0, 0]; gameState.remaining = 9; gameState.currentPlayer = data.currentPlayer; break; case 'opponent': if (gameState.opponent) { console.warn('The game already has an opponent; disregarding "opponent" message'); return; } gameState.status = 'in-progress'; gameState.opponent = data.clientId; break; case 'move': // Normally we'd have a server validate and apply the moves, but for simplicity we're doing // it in the browser. const { position } = data; const { currentPlayer, board } = gameState; const currentPlayerId = gameState[currentPlayer]; // currentPlayer is 'host' or 'opponent' const isGameInProgress = gameState.status === 'in-progress'; const isBoardPositionAssigned = board[position] !== 0; if (!isGameInProgress || isBoardPositionAssigned || clientId !== currentPlayerId) { console.warn('Disregarding invalid game move message'); return; } // Now that we know that this move is valid, apply it to the array of board positions board[position] = currentPlayer === 'host' ? 1 : 2; // Get an array of the three winning positions, or false if the game is not yet won const winCondition = checkForWinCondition(board, position); if (winCondition) { // The current player is the winner gameState.status = 'win'; gameState.winCondition = winCondition; } else if (--gameState.remaining === 0) { // All board positions are full - nobody wins gameState.status = 'draw'; } else { // It's now the other player's turn gameState.currentPlayer = currentPlayer === 'host' ? 'opponent' : 'host'; } break; } // Dispatch the game state back to the UI so it can be rendered onGameStateUpdated(gameState); }; }

Now we have all of the code required to manage the game state and our connection to the Ably Realtime service. All that is remaining is to implement the views for rendering the game state.

game.vue

This is the first of three views that will be required to render the game’s user interface. The Game component is the last of the three route views we defined in router.js earlier in the tutorial. The logic in the view is fairly straightforward.

The selectPosition property is provided by the return value of the enterGame() function above.

In the mounted lifecycle method at the end of the component, the onGameStateUpdated method is bound to the component and passed as the callback function when calling the enterGame() function, along with the game ID, which is retrieved by accessing the id parameter of the component’s $route property.

Note the GameBoard component, referenced in the template as <game-board> , and used to render the actual board layout.

<template> <div class="game"> <div class="game__board" v-if="otherPlayerName"> <div class="game__other-player-name">Your opponent is {{ otherPlayerName }}</div> <game-board @select-position="selectPosition" :board="board" :interactive="isMyTurn" :winningState="winningState" :isWinner="isWinner" /> <div class="game__turn">{{ currentTurn }}</div> <large-button @click="onReturnToLobby" text="Back to Lobby" v-if="winningState || isDraw" /> </div> <div class="game__waiting" v-else> <div class="game__waiting-message" v-if="!otherPlayerName">Waiting for an opponent...</div> </div> </div> </template> <script> import GameBoard from './game-board'; import LargeButton from './large-button'; import { enterGame } from '../state'; export default { name: 'game', data() { return { gameId: this.$route.params.id, selectPosition: null, otherPlayerName: '', winningState: false, isMyTurn: false, isWinner: false, isDraw: false, board: [0,0,0,0,0,0,0,0,0] }; }, components: { GameBoard, LargeButton, }, computed: { currentTurn() { return this.isDraw ? `Dagnabbit. It's a draw.` : this.winningState ? this.isWinner ? 'You won the game!' : `${this.otherPlayerName} is the winner.` : this.isMyTurn ? `It's your turn` : `Waiting for ${this.otherPlayerName} to make a move`; } }, methods: { onGameStateUpdated(state) { this.otherPlayerName = state.otherPlayerName; this.board = [...state.board]; const currentPlayerId = state[state.currentPlayer]; const isCurrentPlayer = state.clientId === currentPlayerId; if (state.status === 'in-progress') { this.isMyTurn = isCurrentPlayer; } else { if (state.status === 'win') { this.winningState = [...state.winCondition]; this.isWinner = isCurrentPlayer; } else if (state.status ==='draw') { this.isDraw = true; } this.isMyTurn = false; } }, onReturnToLobby() { this.$router.push(`/lobby`); } }, mounted() { this.selectPosition = enterGame(this.gameId, this.onGameStateUpdated.bind(this)); }, }; </script> <style scoped> .game { display: flex; justify-content: center; } .game__turn { margin-top: 40px; } .game__waiting-message { margin: 20px; color: #27627F; } .game__other-player-name { margin-bottom: 40px; } </style>

game-board.vue

The game board is rendered by iterating through the nine-element game board array that is managed in state.js . Each cell is rendered independently, as it can be clicked, and it has several possible states, so it is easier to manage if implemented separately.

Note the winningState property, which is generated by the checkForWinCondition() function we implemented in the state.js file. Also, for each iteration, the state property represents the value of 0 , 1 or 2 , contained within the game board array. Also, the interactive property tells the view whether it is the local player’s turn, thus making the board visibly active, and responsive to click events.

<template> <div :class="{ 'game-board': true, 'game-board--interactive': interactive }"> <template v-for="(state, position) in board"> <game-board-cell :position="position" :state="state" :final="winningState && winningState.includes(position) && (isWinner ? 'win' : 'loss')" :key="position" @select-cell="onClickCell" /> </template> </div> </template> <script> import GameBoardCell from './game-board-cell'; export default { name: 'game-board', props: ['board', 'winningState', 'interactive', 'isWinner'], components: { GameBoardCell, }, methods: { onClickCell(position) { if(this.interactive) { this.$emit('select-position', position); } } } }; </script> <style> .game-board { display: inline-grid; grid-template-rows: 1fr 1fr 1fr; grid-template-columns: 1fr 1fr 1fr; width: 200px; height: 200px; font-size: 48px; } </style>

game-board-cell.vue

The game board cell is the final view that we need to implement. The click handler is used to allow a player to make a move on their turn. Though the click handler is active on both players’ turns, the onClickCell handler (in game-board.vue ) checks looks at the interactive property to determine whether any click events should be ignored. When it is the other player’s turn, static styling removes any appearance of interactivity, making the always-active click event handler a non-issue.

<template> <div :class="['game-board__cell', rowClass, colClass, stateClass, finalClass]" @click="click(position)"> <i v-if="state === 1" class="far fa-circle"></i> <i v-else-if="state === 2" class="fas fa-times"></i> </div> </template> <script> export default { name: 'game-board-cell', props: ['position', 'state', 'final'], computed: { rowClass() { return `game-board__cell--row-${Math.floor(this.position / 3)}`; }, colClass() { return `game-board__cell--col-${this.position % 3}`; }, stateClass() { return `game-board__cell--${this.state ? 'set' : 'unset'}`; }, finalClass() { return this.final ? `game-board__cell--${this.final}` : false; } }, methods: { click(position) { this.$emit('select-cell', position); } } }; </script> <style> .game-board__cell { display: flex; justify-content: center; align-items: center; } .game-board__cell--win { color: #81B60F; } .game-board__cell--loss { color: #73021B; } .game-board--interactive .game-board__cell--unset { background-color: rgba(224, 255, 0, 0.1); cursor: pointer; } .game-board--interactive .game-board__cell--unset:hover { background-color: rgba(224, 255, 0, 0.3); } .game-board__cell--row-0.game-board__cell--col-0 { border-radius: 5px 0 0 0; } .game-board__cell--row-0.game-board__cell--col-2 { border-radius: 0 5px 0 0; } .game-board__cell--row-2.game-board__cell--col-0 { border-radius: 0 0 0 5px; } .game-board__cell--row-2.game-board__cell--col-2 { border-radius: 0 0 5px 0; } .game-board__cell--row-1, .game-board__cell--row-2 { border-top: 5px solid #f7f7f7; } .game-board__cell--col-1, .game-board__cell--col-2 { border-left: 5px solid #f7f7f7; } i.fa-circle { font-size: 48px; } i.fa-times { font-size: 60px; } </style>

That pretty much wraps it up! You can try out a live preview of the game. To play against yourself, open a second instance in a different browser, or in an incognito/private browser window.







Download tutorial source code

git clone https://github.com/ably/tutorials.git

Checkout the tutorial branch:

git checkout vue-tictactoe

And then run the demo locally by adding your Ably API key to server/index.js , and building and running the client and server as described at the start of the tutorial.

Next steps

1. Find out more about Realtime channels & messages

2. Find out more about Presence

3. Find out more about History

4. Find out more about Authentication

5. Learn more about other Ably features by stepping through our other Ably tutorials

6. Gain a good technical overview of how the Ably realtime platform works

7. Get in touch if you need help