Create a realtime chess game with React and Chatkit

  • Graham Cox
October 5th, 2018
You will need a recent version of Node and Create React App installed on your machine.

Introduction

In this tutorial, we are going to build an online chess game, allowing for people to both play games against each other and to spectate on other ongoing games. This will include a matchmaking lobby and per-game chat rooms for spectators whilst the game is in progress.

Prerequisites

In order to follow along, you will need a recent installation of Node.js as we will be using this for both the client and server. The server will be written using the Express Generator, and the client using Create React App, so these need to be installed as well.

Creating initial ChatKit details

Before we can continue, we need to create some basic ChatKit details. This is done by logging in to the ChatKit dashboard for the instance we are using, and navigating to the Instance Inspector.

Once here, we need to create an Administrator user and a Lobby room. The Instance Inspector will start out blank with a single button to create a user:

Press this button, and fill out the form. It is recommended to use a secure User ID that will not be guessed, for example a UUID:

Once done, we then need to create a new room. Navigate to the ROOMS tab and press the CREATE NEW ROOM button:

On here, select our new Administrator user as the room creator, and create a room named “Lobby”:

Creating the basic project structure

Before we can start, we need to set up the basic structure of both our front and backend. This will then allow us to progress through the rest of the article expanding both as needed.

Setting up the UI

In order to create the frontend, first ensure that Create React App is correctly installed then use it to create our project:

    $ create-react-app chess-ui
    $ cd chess-ui

Then we can add some necessary modules for the UI to work:

    $ yarn add semantic-ui-react semantic-ui-css axios @pusher/chatkit pusher-js

Finally, we’ll update App.js to get rid of the boilerplate that we initially get, instead replacing it as follows:

    // chess-ui/src/App.js
    import React from 'react';
    import 'semantic-ui-css/semantic.min.css';
    import { Container } from 'semantic-ui-react';
    class App extends React.Component {
      render() {
        return (
          <Container>
          </Container>
        );
      }
    }
    export default App;

We can also now delete the App.css and logo.svg files since we won’t be using them.

The UI can now be started and seen in the browser:

    $ yarn start

Setting up the backend service

Our backend is going to be created using the Express Generator. Firstly ensure this is installed and then use it as follows:

    $ express --no-view --git chess-backend
    $ cd chess-backend

Again, we now need some standard dependencies:

    $ yarn add @pusher/chatkit-server pusher cors

Next, update app.js to remove the default routes that are generated, since we aren’t going to use them. The file should look as follows:

    // chess-backend/app.js
    var express = require('express');
    var logger = require('morgan');
    var cors = require('cors');

    var app = express();

    app.use(logger('dev'));
    app.use(express.json());
    app.use(express.urlencoded({ extended: false }));
    app.use(cors());

    module.exports = app;

We can also now delete everything in public and routes.

Finally, update bin/www so that the default port is 4000 instead of 3000. This is needed so that we can run the UI and Backend service independently on the same machine.

We can then start our backend service:

    $ yarn start

Displaying the list of rooms

The first thing we want is a list of the existing rooms. These are the lobby where matchmaking will occur as well as the rooms for the actual games to be played in.

Before anything else, we need to have support in our backend system to authenticate the Chatkit sessions. This allows the UI to connect to Chatkit using a particular username and for our own backend to assert that they are allowed to do so.

First, create a new module containing our Chatkit connection. This allows us to use it from multiple routes at the same time. For this, create a new file called routes/chatkit.js as follows:

    var Chatkit = require('@pusher/chatkit-server');

    const chatkit = new Chatkit.default({
        instanceLocator: 'CHATKIT_INSTANCE_LOCATOR',
        key: 'CHATKIT_SECRET_KEY'
    });

Note: make sure you replace CHATKIT_INSTANCE_LOCATOR and CHATKIT_SECRET_KEY with the correct values you obtained earlier.

Next, create a new file called routes/auth.js in the backend application as follows:

    // chess-backend/routes/auth.js
    var express = require('express');
    var router = express.Router();
    const chatkit = require('./chatkit');

    router.post('/', (req, res) => {
        const userId = req.query.user_id;

        chatkit.createUser({
            id: userId,
            name: userId
        })
            .catch(() => {})
            .then(() => {
                const authData = chatkit.authenticate({
                    userId: userId
                });

                res.status(authData.status)
                    .send(authData.body);
            });
    });

    module.exports = router;

Note: this doesn’t do any real authentication. It allows any username entered to join the games.

This code will accept a username, ensure that the user with this username exists, and then return the correct response to indicate that this user has authenticated successfully.

Next we need to add a route for this so that our backend service can actually call this code. For this, update app.js in the backend application as follows:

    // At the top of the file chess-backend/app.js
    var authRouter = require('./routes/auth');

    // Near the bottom of the file chess-backend/app.js
    app.use('/auth', authRouter);

This mounts our authentication router under /auth for our Chatkit instance to call.

Once this is done, make sure that the backend server is running by calling yarn start

Now we can build the UI to show the list of rooms. First, create a component to render the rooms. This will be in src/Rooms.js in the UI project as follows:

    // chess-ui/src/Rooms.js
    import React from 'react';
    import { List, Icon, Header } from 'semantic-ui-react';
    export default function Rooms({ joined, joinable, activeRoom, enterRoom, leaveRoom }) {
        const joinedRooms = joined.map((room) => (
            <List.Item key={room.id}>
                <List.Content floated='right'>
                    <a onClick={() => leaveRoom(room.id)}>Leave</a>
                </List.Content>
                <Icon name={room.id === activeRoom ? 'right triangle' : ''} />
                <List.Content>
                    <a onClick={() => enterRoom(room.id)}>{ room.name }</a>
                </List.Content>
            </List.Item>
        ));
        const joinableRooms = joinable.map((room) => (
            <List.Item key={room.id}>
                <Icon name="" />
                <List.Content>
                    <a onClick={() => enterRoom(room.id)}>{ room.name }</a>
                </List.Content>
            </List.Item>
        ));
        return (
            <div>
                <Header as="h4">Active Rooms</Header>
                <List divided relaxed>
                    { joinedRooms }
                </List>
                <Header as="h4">Joinable Rooms</Header>
                <List divided relaxed>
                    { joinableRooms }
                </List>
            </div>
        );
    }

Next we want a component for displaying when the user has logged in and is viewing the game interface. This will be in src/Games.js as follows:

    // chess-ui/src/Games.js
    import React from 'react';
    import { Segment, Grid } from 'semantic-ui-react';
    import { TokenProvider, ChatManager } from '@pusher/chatkit';
    import Rooms from './Rooms';
    import Chat from './Chat';
    export default class Games extends React.Component {
        state = {
            joined: [],
            joinable: []
        };
        constructor(props) {
            super(props);
            this.chatManager = new ChatManager({
                instanceLocator: 'CHATKIT_INSTANCE_LOCATOR',
                tokenProvider: new TokenProvider({
                    url: "http://localhost:4000/auth",
                }),
                userId: props.username
            });
            this.chatManager.connect().then(currentUser => {
                this.setState({
                    currentUser: currentUser
                });
                currentUser.getJoinableRooms().then((rooms) => {
                    let lobby = rooms.find(room => room.name === 'Lobby');
                    if (lobby) {
                        currentUser.joinRoom({ roomId: lobby.id });
                    } else {
                        lobby = currentUser.rooms.find(room => room.name === 'Lobby');
                    }
                    if (lobby) {
                        this.setState({
                            lobbyId: lobby.id,
                            activeRoom: lobby.id
                        });
                    }
                });
                setInterval(this._pollRooms.bind(this), 5000);
                this._pollRooms();
            }).catch((e) => {
                console.log('Failed to connect to Chatkit');
                console.log(e);
            });
        }
        _pollRooms() {
            const { currentUser } = this.state;
            currentUser.getJoinableRooms()
                .then((rooms) => {
                    this.setState({
                        joined: currentUser.rooms,
                        joinable: rooms
                    })
                });
        }
        _enterRoom(id) {
            const { currentUser } = this.state;
            currentUser.joinRoom({ roomId: id })
                .then(() => {
                    this.setState({
                        activeRoom: id
                    });
                    this._pollRooms();
                })
                .catch(() => {
                    console.log('Failed to enter room');
                });
        }
        _leaveRoom(id) {
            const { currentUser } = this.state;
            currentUser.leaveRoom({ roomId: id })
                .then(() => {
                    this._pollRooms();
                })
                .catch(() => {
                    console.log('Failed to leave room');
                });
        }
        render() {
            const { currentUser } = this.state;
            let chat;
            if (currentUser) {
                const room = currentUser.rooms.find((room) => room.id === this.state.activeRoom);
                if (room) {
                    chat = <Chat user={currentUser} room={room} />
                }
            }
            return (
                <Segment>
                    <Grid>
                        <Grid.Column width={4}>
                            <Rooms joined={this.state.joined}
                                   joinable={this.state.joinable}
                                   activeRoom={this.state.activeRoom}
                                   enterRoom={this._enterRoom.bind(this)}
                                   leaveRoom={this._leaveRoom.bind(this)} />
                        </Grid.Column>
                        <Grid.Column width={12}>
                        </Grid.Column>
                    </Grid>
                </Segment>
            );
        }
    }

Note: make sure you replace CHATKIT_INSTANCE_LOCATOR with the value you obtained earlier.

Note: this uses the URL http://localhost:4000/auth for the authentication endpoint. This is the one we wrote in our backend earlier and is running on our local system.

This will maintain the Chatkit connection and will periodically update the list of rooms - both ones that we are currently members of and ones that we are able to join - and provide these to our component for rendering the rooms list. We also keep track of the Room ID of the room called “Lobby” - since this is a special room - and we make sure that the user is a member of this room when they first open the app.

Now we want a component for managing the login screen. This is the screen the user will first see, where they can enter their name and proceed to the actual game screen. This will go in src/Login.js as follows:

    // chess-ui/src/Login.js
    import React from 'react';
    import { Segment, Button, Form } from 'semantic-ui-react';
    export default class Login extends React.Component {
        state = {
            username: ''
        };
        render() {
            return (
                <Segment>
                    <Form onSubmit={this.handleFormSubmit.bind(this)}>
                        <Form.Field>
                            <label>Username</label>
                            <input placeholder='Username' 
                                   value={this.state.username} 
                                   autoFocus
                                   onChange={this.handleUsernameChange.bind(this)} />
                        </Form.Field>
                        <Button type='submit'>Log in</Button>
                    </Form>  
                </Segment>
            );
        }
        handleUsernameChange(e) {
            this.setState({
                username: e.target.value
            });
        }
        handleFormSubmit() {
            if (this.state.username) {
                this.props.login(this.state.username);
            }
        }
    }

Finally we render all of this into our main application. Update src/App.js as follows:

    // chess-ui/src/App.js
    import React from 'react';
    import 'semantic-ui-css/semantic.min.css';
    import { Container } from 'semantic-ui-react';
    import Login from './Login';
    import Games from './Games';
    class App extends React.Component {
      state = {};
      render() {
        let contents;
        if (this.state.username) {
          contents = <Games username={this.state.username} />
        } else {
          contents = <Login login={this.enterGame.bind(this)} />
        }
        return (
          <Container>
            { contents }
          </Container>
        );
      }
      enterGame(username) {
        this.setState({
          username: username
        });
      }
    }
    export default App;

Ensure that your UI is running and you should now see the following:

Creating the chat room

Once we can log in and list the rooms, we want to actually create a usable chatroom. This will allow the users to chat to each other, and to challenge each other to games. We will also have a chat room per current game, so that both the players and spectators can converse whilst the game is going on.

The first thing we need is a new component to represent the chat. This will be in src/Chat.js as follows:

    // chess-ui/src/Chat.js
    import React from 'react';
    import { Grid, List, Comment, Form, Input } from 'semantic-ui-react';
    export default class Chat extends React.Component {
        constructor(props) {
            super(props);
            this.state = {
                users: props.room.users,
                messages: [],
                newMessage: ''
            };
            props.user.subscribeToRoom({
                roomId: props.room.id,
                messageLimit: 100,
                hooks: {
                    onUserJoined: (user) => {
                        this.setState({
                            users: props.room.users
                        });
                    },
                    onUserLeft: (user) => {
                        this.setState({
                            users: props.room.users
                        });
                    },
                    onNewMessage: (message) => {
                        const messages = this.state.messages;
                        messages.push({
                            id: message.id,
                            user: message.senderId,
                            message: message.text
                        });
                        this.setState({
                            messages: messages
                        });
                    }
                }
            })
        }
        render() {
            const users = this.state.users
                .filter((user) => user.id !== this.props.user.id)
                .map((user) => (
                    <List.Item key={user.id}>
                        <List.Content>
                            { user.name }
                        </List.Content>
                    </List.Item>
                ));
            const messages = this.state.messages
                .map((message) => {
                    return (
                        <Comment key={message.id}>
                            <Comment.Content>
                                <Comment.Author>{ message.user }</Comment.Author>
                                <Comment.Text>{ message.message }</Comment.Text>
                            </Comment.Content>
                        </Comment>
                    );        
                });
            return (
                <Grid>
                    <Grid.Row>
                        <Grid.Column width={12}>
                            <Comment.Group style={{height: '20em', overflow: 'auto'}}>
                                { messages }
                            </Comment.Group>
                            <div style={{ float:"left", clear: "both" }} ref={(el) => { this.messagesEnd = el; }} />
                        </Grid.Column>
                        <Grid.Column width={4}>
                            <List style={{maxHeight: '20em', overflow: 'auto'}}>
                                <List.Item>
                                    <b>
                                        { this.props.user.name }
                                    </b>
                                </List.Item>
                                { users }
                            </List>
                        </Grid.Column>
                    </Grid.Row>
                    <Grid.Row>
                        <Grid.Column width={16}>
                            <Form onSubmit={this._handleSubmit.bind(this)}>
                                <Input action='Post'
                                       placeholder='New Message...'
                                       value={this.state.newMessage}
                                       fluid
                                       autoFocus
                                       onChange={this._handleNewMessageChange.bind(this)} />
                            </Form>
                        </Grid.Column>
                    </Grid.Row>
                </Grid>
            );
        }
        componentDidMount() {
            this._scrollToBottom();
        }
        componentDidUpdate() {
            this._scrollToBottom();
        }
        _scrollToBottom() {
            this.messagesEnd.scrollIntoView({ behavior: "smooth" });
        }
        _handleNewMessageChange(e) {
            this.setState({
                newMessage: e.target.value
            });
        }
        _handleSubmit() {
            const { newMessage } = this.state;
            const { user, room } = this.props;
            user.sendMessage({
                text: newMessage,
                roomId: room.id
            });
            this.setState({
                newMessage: ''
            });
        }
    }

There’s a lot going on here, so let’s break it down.

First we construct the new component. This sets some initial state - which includes the initial set of room members - and sets up some callbacks for when interesting things happen to the room. These callbacks allow us to keep the list of users in sync, and to automatically react when new messages are posted. We also indicate that we want to retrieve the most recent 100 messages from the chat.

After this we actually render the room. This renders a list of messages - fixed to a height of 20em to make it manageable - and a list of the room members. We also render a form to allow the user to type a new message to send to the room.

Next we have some helper code to ensure that the list of messages is always scrolled to the bottom after any changes.

Finally we have the handlers for the current user typing a message and sending it.

Next we need to add this into the actual Games UI. Add a new import to the top of src/Games.js:

    import Chat from './Chat';

Then we can update the render method of src/Games.js as follows:

    // chess-ui/src/Games.js
        render() {
            const { currentUser } = this.state;
            let chat;
            if (currentUser) {
                const room = currentUser.rooms.find((room) => room.id == this.state.activeRoom);
                if (room) {
                    chat = <Chat user={currentUser} room={room} key={room.id} />
                }
            }
            return (
                <Segment>
                    <Grid>
                        <Grid.Column width={4}>
                            <Rooms joined={this.state.joined}
                                   joinable={this.state.joinable}
                                   activeRoom={this.state.activeRoom}
                                   enterRoom={this._enterRoom.bind(this)}
                                   leaveRoom={this._leaveRoom.bind(this)} />
                        </Grid.Column>
                        <Grid.Column width={12}>
                            { chat }
                        </Grid.Column>
                    </Grid>
                </Segment>
            );
        }

This allows us to render the chat room for the room the user is currently active in, using the component we’ve just defined above.

Challenging a player to a game

Now that we have our chat room that we can talk in, we want to actually be able to challenge players to a game of chess. This will all be done in the chat room using features that are provided to us.

First, add some new functions to the Chat class inside src/Chat.js:

    // chess-ui/src/Chat.js
    _challengePlayer(player) {
        const { user, room } = this.props;
        user.sendMessage({
            text: `I challenge ${player.name} to a game`,
            roomId: room.id,
            attachment: {
                link: `urn:player:${player.id}`,
                type: 'file',
                fetchRequired: false
            }
        });
    }
    _acceptChallenge(player) {
        console.log(player);
    }

The second of these is just a placeholder for now - we will fill it out properly later. The first of these simply sends a message - the same as if it were typed out - but makes use of the Chatkit Attachments to indicate that this message is actually a challenge from one player to another. Our attachment is a specially formed URI that indicates who the player is that we are challenging.

Note: Chatkit Attachments must be either the actual file data or a URI linking to the file. We’re abusing it slightly here to provide a link to another player instead, but it works great for our needs.

Next we want to handle the fact that the messages we receive might have an attachment indicating that it was a challenge. This is done by updating the onNewMessage callback that we defined earlier:

    // chess-ui/src/Chat.js
    onNewMessage: (message) => {
        const messages = this.state.messages;
        let opponent;
        if (message.attachment && message.attachment.link && message.attachment.link.startsWith('urn:player:')) {
            opponent = message.attachment.link.substring(11);
            if (opponent !== props.user.id) {
                opponent = undefined;
            }
        }
        messages.push({
            id: message.id,
            user: message.senderId,
            message: message.text,
            opponent: opponent
        });
        this.setState({
            messages: messages
        });
    }

This is the same as before, but if the message has an attachment that starts with the string “urn:player:” and that player is myself then we put this into our messages structure for rendering.

Now we need to render this concept. This involves two changes to the render method in the Chat class. First, we update the block that renders each user in the room to include a “Challenge” button:

    // chess-ui/games/Chat.js
    const users = this.state.users
        .filter((user) => user.id !== this.props.user.id)
        .map((user) => (
            <List.Item key={user.id}>
                <List.Content floated='right'>
                    <a onClick={() => this._challengePlayer(user)}>Challenge</a>
                </List.Content>
                <List.Content>
                    { user.name }
                </List.Content>
            </List.Item>
        ));

And second we update the block that renders each message in the room to include the action for accepting a challenge:

    // chess-ui/src/Chat.js
    const messages = this.state.messages
        .map((message) => {
            let acceptGame;
            if (message.opponent) {
                acceptGame = (
                    <Comment.Actions>
                        <Comment.Action onClick={() => this._acceptChallenge(message.user)}>Accept Challenge</Comment.Action>
                    </Comment.Actions>
                );
            }
            return (
                <Comment key={message.id}>
                    <Comment.Content>
                        <Comment.Author>{ message.user }</Comment.Author>
                        <Comment.Text>{ message.message }</Comment.Text>
                        { acceptGame }
                    </Comment.Content>
                </Comment>
            );
        });

We can now see that players can challenge each other and the person being challenged is given a link to accept.

Starting a game

Once we can challenge players to a game, we need to actually be able to accept a challenge and start a game. We are going to store the game state on our backend, and we will access this using an ID that is the Room ID of the room the game is being played in. This helps keep everything consistent.

First we need some handlers for managing the game. These will go into a new file in the backend application called routes/games.js as follows:

    // chess-backend/routes/games.js
    var express = require('express');
    var router = express.Router();
    const games = {};
    router.post('/', (req, res) => {
        const room = req.body.room;
        const white = req.body.whitePlayer;
        const black = req.body.blackPlayer;
        const newGame = {
            players: {
                [white]: 'white',
                [black]: 'black'
            },
            board: [
                ['BR', 'BN', 'BB', 'BQ', 'BK', 'BB', 'BN', 'BR'],
                ['BP', 'BP', 'BP', 'BP', 'BP', 'BP', 'BP', 'BP'],
                ['  ', '  ', '  ', '  ', '  ', '  ', '  ', '  '],
                ['  ', '  ', '  ', '  ', '  ', '  ', '  ', '  '],
                ['  ', '  ', '  ', '  ', '  ', '  ', '  ', '  '],
                ['  ', '  ', '  ', '  ', '  ', '  ', '  ', '  '],
                ['WP', 'WP', 'WP', 'WP', 'WP', 'WP', 'WP', 'WP'],
                ['WR', 'WN', 'WB', 'WQ', 'WK', 'WB', 'WN', 'WR']
            ]
        };
        games[room] = newGame;
        res.send(newGame);
    });
    router.get('/:room', (req, res) => {
        const room = req.params.room;
        const game = games[room];
        if (game) {
            res.send(game);
        } else {
            res.status(404).send(`Game not found: ${room}`);
        }
    });
    router.post('/:room', (req, res) => {
        const room = req.params.room;
        const player = req.body.player;
        const fromRow = req.body.fromRow;
        const fromColumn = req.body.fromColumn;
        const toRow = req.body.toRow;
        const toColumn = req.body.toColumn;
        const game = games[room];
        if (game) {
            const piece = game.board[fromRow][fromColumn];
            const playerSide = game.players[player];
            if (piece == '  ') {
                res.status(400).send(`No piece in that square: ${fromRow}x${fromColumn}`);
            } else if (!playerSide) {
                res.status(400).send(`Not a player: ${player}`);
            } else if ((playerSide === 'white' && piece[0] !== 'W') || (playerSide === 'black' && piece[0] !== 'B')) {
                res.status(400).send(`Not your piece. Player=${playerSide}, Piece=${piece}`);
            } else {
                game.board[fromRow][fromColumn] = '  ';
                game.board[toRow][toColumn] = piece;
                res.send(game);
            }
        } else {
            res.status(404).send(`Game not found: ${room}`);
        }
    });
    module.exports = router;

This gives us handlers for creating a new game, for retrieving the current state of a game, and for making moves in a game.

Note: we have put no logic in here to validate the moves, or to support certain special moves such as castling or en passant. We also have no support for promotion of pawns that reach the eighth rank.

Next we need to add a route for this so that our backend service can actually call this code. For this, update app.js in the backend application as follows:

    // At the top of the file chess-backend/app.js
    var gameRouter = require('./routes/games');

    // Near the bottom of the file chess-backend/app.js
    app.use('/games', gameRouter);

This mounts our gameplay router under /games for our UI to call.

Once this is done, stop and restart the backend server by calling yarn start.

Next, we need to update the UI to make use of this to set up a game. First we’ll update the src/Chat.js file in the webapp project, replacing our _acceptChallenge function as follows:

    // chess-ui/src/Chat.js
        _acceptChallenge(player) {
            const { user } = this.props;
            user.createRoom({
                name: `${user.id} vs ${player}`,
                addUserIds: [player]
            }).then((room) => {
                this.props.startedGame(room.id, user.id, player);
            });
        }

This now creates a new Chatkit room for the new game, and then calls a callback that we’ve been provided to tell the overarching component what’s happened.

Now we need to implement this callback. This is all in src/Games.js. Firstly we need a new dependency on axios, by adding this to the top of the file:

    import axios from 'axios';

Then we want to write our callback function. This is a new function inside the Games class as follows:

    // chess-ui/src/Games.js
    _startedGame(roomId, white, black) {
      axios.request({
          url: 'http://localhost:4000/games',
          method: 'POST',
          data: {
              room: roomId,
              whitePlayer: white,
              blackPlayer: black
          }
      })
      .then((response) => {
          this.setState({
              activeRoom: roomId
          });
          this._pollRooms();
      });
    }

Note: this uses the URL http://localhost:4000/games for the games endpoint. This is the one we wrote in our backend earlier and is running on our local system.

This will call our new backend handler to create a new game, and then it will update the component so that the newly created room is active - forcing the player that accepted the challenge to immediately see the game.

Finally we need to provide the callback to our Chat component. This is done by updating the render method where we render the Chat component as follows:

    chat = <Chat user={currentUser} room={room} key={room.id} startedGame={this._startedGame.bind(this)} />

Playing the game

The next thing we need to be able to do is to support playing the game. This means displaying the chess board and allowing players to interact with it.

First we need a new component for rendering the play area. This will go in src/GameBoard.js in the webapp, as follows:

    // chess-ui/chat/GameBoard.js
    import React from 'react';
    import axios from 'axios';
    import { Grid, List } from 'semantic-ui-react';
    const PIECES = {
        'WK': '♔',
        'WQ': '♕',
        'WR': '♖',
        'WB': '♗',
        'WN': '♘',
        'WP': '♙',
        'BK': '♚',
        'BQ': '♛',
        'BR': '♜',
        'BB': '♝',
        'BN': '♞',
        'BP': '♟'
    }
    export default class GameBoard extends React.Component {
        state = {
            board: [],
            players: {}
        };
        render() {
            const board = this.state.board
                .map((row, rowIndex) => {
                    return row.map((piece, columnIndex) => {
                        const pieceSymbol = PIECES[piece];
                        if (pieceSymbol) {
                            return <text key={rowIndex + '-' + columnIndex} x={columnIndex} y={rowIndex + 0.8} style={{font: '1px sans-serif'}}>{pieceSymbol}</text>
                        }
                        return undefined;
                    }).filter((value) => value);
                });
            let activeCell;
            if (this.state.activeCell) {
                activeCell = <rect x={this.state.activeCell.x} y={this.state.activeCell.y} width="1" height="1" fillOpacity="0.5" fill="#F00" />
            }
            const players = Object.keys(this.state.players).map((player) => {
                const color = this.state.players[player];
                return (
                    <List.Item>
                        <List.Header>{ color }</List.Header>
                        { player }
                    </List.Item>
                );
            });
            return (
                <Grid>
                    <Grid.Row columns={2}>
                        <Grid.Column>
                            <svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="-.05 -.05 8.1 8.1">
                                <rect x="-.5" y="-.5" width="9" height="9" style={{fill: '#F4A460'}} />
                                <path fill="#FFEBCD" d="M0,0H8v1H0zm0,2H8v1H0zm0
                                2H8v1H0zm0,2H8v1H0zM1,0V8h1V0zm2,0V8h1V0zm2
                                0V8h1V0zm2,0V8h1V0z"/>
                                { [].concat.apply([], board) }
                                { activeCell }
                                <rect x="0" y="0" width="8" height="8" fillOpacity="0.1" onClick={(e) => this._handleBoardClick(e)} />
                            </svg>
                        </Grid.Column>
                        <Grid.Column>
                            <List>
                                { players }
                            </List>
                        </Grid.Column>
                    </Grid.Row>
                </Grid>
            );
        }
        componentDidMount() {
            this._refreshGame();
        }
        _handleBoardClick(e) {
            const dim = e.target.getBoundingClientRect();
            const x = e.clientX - dim.left;
            const y = e.clientY - dim.top;
            const cellX = Math.floor((x / 200) * 8);
            const cellY = Math.floor((y / 200) * 8);
            if (this.state.activeCell) {
                if (this.state.activeCell.x === cellX && this.state.activeCell.y === cellY) {
                    this.setState({
                        activeCell: null
                    });
                } else {
                    axios.request({
                        method: 'POST',
                        url: 'http://localhost:4000/games/' + this.props.room,
                        data: {
                            player: this.props.user.id,
                            fromRow: this.state.activeCell.y,
                            fromColumn: this.state.activeCell.x,
                            toRow: cellY,
                            toColumn: cellX
                        }
                    });
                    this.setState({
                        activeCell: null
                    });
                }
            } else {
                this.setState({
                    activeCell: {
                        x: cellX,
                        y: cellY
                    }
                });
            }
        }
        _refreshGame() {
            axios.request({
                url: 'http://localhost:4000/games/' + this.props.room
            })
            .then((response) => {
                this.setState({
                    board: response.data.board,
                    players: response.data.players
                });
            });
        }
    }

Again, there is a lot going on here, so we’ll break it down.

To start with, we have a mapping between the codes we defined in our backend for the different chess pieces and the unicode symbols that we will use to render them. We then have our component that renders the player area. The render function for this generates an SVG on the fly, drawing the board and putting the pieces in the correct places. We also put a red square over the active square - if there is one - and have a transparent square over the entire board for the players to click on. We also render next to the board the players, indicating who is playing which color.

Next we have a handler for when the board is clicked on. This calculated which square was clicked on, where (0, 0) is the top left corner and (7, 7) is the bottom right corner, and then reacts accordingly.

Finally, we have a handler to load the current game state from the backend so that we have something to render.

Next we need to support rendering this game board. For this we update the src/Chat.js file. First add a new import at the top as follows:

    import GameBoard from './GameBoard';

And then make use of it. For this we add a new line into the render method, immediately before the Comment.Group component is rendered:

    // chess-ui/src/Chat.js
    <Grid.Column width={12}>
        { this.props.game && <GameBoard room={this.props.game} user={this.props.user} /> }
        <Comment.Group style={{height: '20em', overflow: 'auto'}}>
            { messages }
        </Comment.Group>
        <div style={{ float:"left", clear: "both" }} ref={(el) => { this.messagesEnd = el; }} />
    </Grid.Column>

Finally, we need to update src/Games.js to provide a game ID to the Chat component if this is not the lobby that we are displaying. For this, update the start of the render method as follows:

    // chess-ui/src/Games.js
    if (currentUser) {
        const room = currentUser.rooms.find((room) => room.id == this.state.activeRoom);
        if (room) {
            const game = this.state.activeRoom !== this.state.lobbyId && this.state.activeRoom;
            chat = <Chat user={currentUser} room={room} key={room.id} startedGame={this._startedGame.bind(this)} game={game} />
        }
    }

Realtime updates

We are now going to introduce Pusher Channels to indicate that a given game has updated and cause all players to see the updated board.

The first step here is to send the messages that the board has changed. For this, we need to update routes/games.js in the backend application. First add the following towards the top of the file:

    // chess-backend/routes/games.js
    var Pusher = require('pusher');

    var pusher = new Pusher({
      appId: 'PUSHER_APP_ID',
      key: 'PUSHER_APP_KEY',
      secret: 'PUSHER_APP_SECRET',
      cluster: 'PUSHER_CLUSTER',
      encrypted: true
    });

Note: make sure to replace PUSHER_APP_ID, PUSHER_APP_KEY, PUSHER_APP_SECRET and PUSHER_CLUSTER with the values that you obtained when creating the Pusher Channels app instance.

Then we can update the route handler for making a move in the game to send an appropriate event:

    // chess-backend/routes/games.js
    game.board[fromRow][fromColumn] = '  ';
    game.board[toRow][toColumn] = piece;
    res.send(game);
    pusher.trigger('game-' + room, 'board-updated', {});

Note: we don’t actually send the new state of the game, just an indication that it’s changed. The client will always get the new state by calling the server, which ensures that there is no risk of sending a stale state.

At this point the backend can be restarted, using yarn start.

Now we need to subscribe to updates on the game. To do this we need to update src/GameBoard.js in the webapp. Firstly, add the following towards the top of the file:

    // chess-ui/src/GameBoard.js
    import Pusher from 'pusher-js';
    var pusher = new Pusher('PUSHER_APP_KEY', {
        cluster: 'PUSHER_CLUSTER',
        forceTLS: true
    });

Note: make sure to replace PUSHER_APP_KEY and PUSHER_CLUSTER with the values that you obtained when creating the Pusher Channels app instance.

Then we can replace the ComponentDidMount method with the following:

    // chess-ui/src/GameBoard.js
    componentDidMount() {
        const room = this.props.room;
        const channel = pusher.subscribe(`game-${room}`);
        channel.bind('board-updated', () => {
            this._refreshGame();
        });
        this._refreshGame();
    }

We also want to add a new componentWillUnmount method to tidy up the subscriptions when a user leaves a room:

    // chess-ui/src/GameBoard.js
    componentWillUnmount() {
        const room = this.props.room;
        pusher.unsubscribe(`game-${room}`);
    }

We can now not only play the game, but watch it unfold in realtime.

Deleting Rooms

The only thing left is to tidy up the rooms after games have finished. This means deleting the rooms when the players have left, so that they don’t clutter the interface up.

We are going to implement this by deleting the room when the last of the two chess players leaves, meaning that any spectators will also get booted out.

In order to delete a room, a player needs appropriate permissions. These are managed by roles. As such, we will need to create a new role to use here. In the Instance Inspector for your Pusher Chatkit application, open up the ROLES tab and press the CREATE NEW ROLE button. Use this dialog to create a Room scoped role called “Player”:

This role then needs to be assigned the “room:delete”:

Now we can assign the “Player” role to the two users that are actually playing a game, scoped to the room that they are playing the game in. This is done by updating routes/games.js in the backend application. First we need to import our Chatkit connection by adding the following to the top of the file:

    var chatkit = require('./chatkit');

Next we want to update the route for creating a new game so that it will assign the role to our players. This is done by updating the route handler as follows:

    // chat-backend/routes/games.js
    games[room] = newGame;
    chatkit.assignRoomRoleToUser({
        userId: white,
        roleName: 'Player',
        roomId: room
    });
    chatkit.assignRoomRoleToUser({
        userId: black,
        roleName: 'Player',
        roomId: room
    });
    res.send(newGame);

Once this is done, stop and restart the backend server by calling yarn start.

Next we want to delete the room if the last of the players leaves. In order to do this, we need to be able to determine the players that are in the room and compare then.

First we need to add a new method to the GameBoard class in src/GameBoard.js in our webapp, which will give us the list of users that are players of this game, as follows:

    // chess-ui/src/GameBoard.js
    getPlayers() {
        return Object.keys(this.state.players);
    }

Next we want to propagate this up through the Chat component. In order to do this, we first need a reference to the GameBoard by updating the render method as follows:

    // chess-ui/src/Chat.js
    { this.props.game && <GameBoard room={this.props.game} user={this.props.user} ref={(child) => { this._gameBoard = child; }}/> }

Then we can add a new method to this class to compare the entire set of users with the players, as follows:

    // chess-ui/src/Chat.js
    getPlayersInRoom() {
        const players = this._gameBoard ? this._gameBoard.getPlayers() : [];
        const playersInRoom = this.state.users
            .filter((user) => players.includes(user.id));
        return playersInRoom;
    }

Finally we can make use of this to determine what to do when leaving a room. For this we need to also store a reference to the Chat component that we are currently rendering so that we can determine if the current user is the last player. Update the render method of the Games class as follows:

    // chess-ui/src/Games.js
    chat = <Chat user={currentUser} room={room} key={room.id} startedGame={this._startedGame.bind(this)} game={game} ref={(child) => { this._chat = child; }}/>

Then update the _leaveRoom method to actually delete the room if we were the last of the actual chess players in it:

    // chess-ui/src/Games.js
    _leaveRoom(id) {
      const { currentUser } = this.state;
      if (this._chat) {
          const playersInRoom = this._chat.getPlayersInRoom();
          if (playersInRoom.length === 1 && playersInRoom[0].id === currentUser.id) {
              currentUser.deleteRoom({ roomId: id });
          }
      }
      currentUser.leaveRoom({ roomId: id })
          .then(() => {
              this._pollRooms();
          })
          .catch(() => {
              console.log('Failed to leave room');
          });
    }

Finally, we now need to ensure that you can only leave a room that you are currently actively looking at. This is a change to src/Rooms.js to only render the “Leave” link for the current room. For this, update the Rooms function as follows:

    // chess-ui/src/Rooms.js
    const joinedRooms = joined.map((room) => (
      <List.Item key={room.id}>
          { room.id === activeRoom && (
              <List.Content floated='right'>
                  <a onClick={() => leaveRoom(room.id)}>Leave</a>
              </List.Content>)
          }
          <Icon name={room.id === activeRoom ? 'right triangle' : ''} />
          <List.Content>
              <a onClick={() => enterRoom(room.id)}>{ room.name }</a>
          </List.Content>
      </List.Item>
    ));

We can now only ever leave a room that we are actively looking at, and if we leave the room when we are the last chess player, the system will automatically delete the entire room.

Summary

This tutorial shows how we can create an online game, providing a fully featured chat environment using Pusher Chatkit and realtime updates of gameplay using Pusher Channels. All of this done in HTML5 using standard libraries and techniques that are increasingly common across the web today. The full source code for this can be seen on GitHub.

  • Channels
  • Chatkit

© 2018 Pusher Ltd. All rights reserved.

Pusher Limited is a company registered in England and Wales (No. 07489873) whose registered office is at 160 Old Street, London, EC1V 9BW.