Tic-Tac-Toe game based on ReactJS and Syncano
Online computer games are a massive industry. Playing alone or with a friend in the same room is old news. Currently, an infinite number of games offer online multiplayer gameplay. Multiplayer gameplay is not hard to create if you use the proper tools such as Syncano. This Tic-Tac-Toe app will show you how to do it.
- Visual layer was written in React.
- Player's data and current board status is a representation of Data Objects created on Syncano platform.
- The Flux architecture is kept by Actions and Stores provided by Reflux.
To make this application work follow the steps below:
- First you need to have Node.js v6.2.2 installed. You can find it here
- You will also need application files. They're available in this repository or can be downloaded here.
- When you unzip the repository files run your command line and move to the destination where you've unzipped application files.
- Run
npm install
command - this will install all required packages. - Run
npm start
command to start the Webpack server.
That's it! The Tic-Tac-Toe application runs on localhost:8080
so you can go and play, but remember! This game requires two players :). Of course you can hack this by opening another tab with localhost:8080
and play with yourself.
If you'd like to see a working live example, go here.
To better understand how the application works you can check out the already set up Syncano backend by installing the Demo App. First, you'll need to have a Syncano account, which can be created here. If you already have one, just go to Syncano dashboard and login. Go to Demo Apps in the header section and install the one named Tic-Tac-Toe. Now you should see a tic-tac-toe
Instance on the Shared Instances list. It contains all the data to make your application work.
If you would like to use your own Instance, you will have to edit src/Utils/Config.js
file with your data.
Players in the game are represented by Syncano Data Objects. Two players can play simultaneously and each one of them has an is_connected
field. This field is telling the application if another player can join the game. If both players have this field set to true
, the application will display proper notification. After a new player joins the game, the connectPlayer
Action will be called. Actions.connectPlayer()
is updating Data Object is_connected
field from false
to true
.
connectPlayer
action:
import Reflux from 'reflux';
import Connection from '../Utils/Connection';
import Config from '../Utils/Config';
...
let Actions = Reflux.createActions({
connectPlayer: {
children: ['completed', 'failure'],
asyncResult: true
},
...
});
Actions.connectPlayer.listen((currentPlayerId) => {
Object.assign(playersParams, {id: currentPlayerId});
Connection.DataObject.please().update(playersParams, {is_connected: true})
.then(Actions.connectPlayer.completed)
.catch(Actions.connectPlayer.failure);
});
First of all, let's talk about components. I won't go through every single component because most of them - if you know even just the basics of React - are really simple - take props
and show them in a proper place. One of them - Board.jsx
- is a bit more complicated and needs special attention. It joins most of the other components inside and holds some logic around updating Data Objects.
The first method in Board.jsx file is ComponentWillMount()
:
componentWillMount() {
Actions.fetchBoard();
Actions.enableBoardPoll();
Actions.enablePlayersPoll();
}
As you can see, it calls 3 Actions
methods:
Actions.fetchBoard()
- handles getting Data Objects properlyActions.enableBoardPoll()
- listens on changes inData Objects
holding board dataActions.enablePlayersPoll()
- listens on changes inData Objects
holding players info
The data flow looks like this:
Actions
get the data from SyncanoStores
are listening to theseActions
- When the
Store
sees that anAction
was completed, it pushes the data into a component - The component renders after it receives new data
fetchBoard
action:
import Reflux from 'reflux';
import Connection from '../Utils/Connection';
import Config from '../Utils/Config';
...
let Actions = Reflux.createActions({
fetchBoard: {
children: ['completed', 'failure'],
asyncResult: true
},
...
});
let boardParams = {
instanceName: Config.instanceName,
className: Config.boardClassName
};
...
Actions.fetchBoard.listen(() => {
Connection.DataObject.please().list(boardParams)
.then(Actions.fetchBoard.completed)
.catch(Actions.fetchBoard.failure);
});
enableBoardPoll
action:
Actions.enableBoardPoll.listen(() => {
Connection.Channel.please().get(channelParams)
.then(Actions.enableBoardPoll.completed)
.catch(Actions.enableBoardPoll.failure);
});
enablePlayersPoll
action:
Actions.enablePlayersPoll.listen(() => {
Object.assign(channelParams, {name: 'tictactoeplayers'});
Connection.Channel.please().get(channelParams)
.then(Actions.enablePlayersPoll.completed)
.catch(Actions.enablePlayersPoll.failure);
});
Now when players are connected, and we have data fetched, we can see what is happening after a player clicks on a field on the game board (handleFieldClick()
method). The clicked field is updated in Syncano via updateFileld
action and the turn is switched via switchTurn
action. If you look at Data Objects
in the tictactoeplayers
class in Syncano you will notice that there is a field named is_player_turn
. The updateField
action calls this field and it's updated in both Data Objects to simulate switching turn. As you can see there's a setState
method inside. It updates the clicked field value locally, so that the user doesn't have to wait for the API call response. It will also be updated for the opponent after the API call is finished.
handleFieldClick(dataObjectId, index) {
let state = this.state;
let value = state.turn;
if (state.items[index].value === null) {
state.items[index].value = value;
this.setState(state, () => {
});
Actions.updateField(dataObjectId, value);
Actions.switchTurn(state.currentPlayer.id, state.opponent.id);
}
}
Now it's time to look on the opponent's side. To understand how the opponent will see the response to our click, we have to look into the Store
and find methods named onEnableBoardPollCompleted()
and onEnablePlayersPollCompleted()
. We can see that when an update on Data Objects
appears, the proper Action
that fetches the Data Objects
will be called. This will update the whole board and the opponent will see changes on his board.
Actions that enable listening on Data Objects
changes:
onEnableBoardPollCompleted(channel) {
let poll = channel.poll();
poll.on('message', () => {
Actions.fetchBoard();
});
},
onEnablePlayersPollCompleted(channel) {
let poll = channel.poll();
poll.on('message', () => {
Actions.fetchPlayers();
});
}
When you look at the renderFields
method, you will see that the field can be disabled in a few cases. One of them is checking whose turn it is. Do you remember the switchTurn
action? Great! It is updating the players Data Objects
. Because we have enabled listening on those Data Objects
changes, both clients will be notified about the change. Because of this, only one player will be able to make a move at a time.
Every game should have a winner! Fortunately the rules of this game are not complicated so we can define all winning cases (see the src/Stores/Store.js
file):
winCombinations: [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]],
winCombinations
is an array of arrays. Each array element contains board indexes that make a winning combination. For example a [0, 3, 6]
combination is:
X - -
X - -
X - -
So it's a winner!
Checking winning combinations method:
checkWinner() {
let items = this.data.items; // Data Objects fetched from 'tictactoe' Syncano class
let currentPlayer = this.data.currentPlayer; // Data Object representing current player
this.data.winCombinations.some((comb) => { // iterate over winning combinations
let testArr = [items[comb[0]].value, items[comb[1]].value, items[comb[2]].value]; // 3 board values with indexes from current winning combination
if (currentPlayer && this.isWinner(testArr)) {
let winner = _.find(this.data.players, ['play_as', testArr[0]]);
Actions.setWinner(winner.id); // mark player as winner in Syncano
comb.forEach((index) => {
this.data.items[index].color = '#F44336'; // mark winning fields in other color
});
}
});
this.data.isGameOver = this.isGameOver(); // helper value representing finished game
this.trigger(this.data); // trigger data into listening components
},
isWinner(items) {
let first = items[0];
if (items.every((item) => item === null) || this.data.winner) {
return false;
}
return items.every((item) => { // check if every item from test array is equal to first - true means that we have a winner
return item === first;
});
},
Alright. We've finished playing and we want to let others play this game. No problem! The Demo App you have installed at the beginning contains a Schedule which triggers a Script every 2 minutes. This Script is checking player's activity, and if any player didn't make a move for 2 minutes, they will be disconnected.
Script cleaning inactive players:
var Moment = require('moment');
var _ = require('lodash');
var Syncano = require('syncano');
var connection = new Syncano({apiKey: CONFIG.apiKey, instance: CONFIG.instanceName});
connection.class(CONFIG.className).dataobject().list().then(function(resp) {
var players = resp.objects;
_.forEach(players, function(player) {
var lastActivity = player.updated_at;
if (Moment(Date.now()).diff(lastActivity, 'minutes') > 5) {
connection.class(CONFIG.className).dataobject(player.id).update({is_connected: false}, function(resp) {
console.log(player.name + ' disconnected...');
})
}
});
})
At this point, only two players can play the game at the same time, but I am working on allowing more people to play. Stay tuned and we will expand this app with new features like rooms, which allow multiple users play the game at the same time.
You can find the source code on GitHub.