DEV Community

Cover image for Building a realtime multiplayer game using React & Conflict-free replicated data types (CRDT) of Yjs
Gandharv for ToolJet

Posted on • Originally published at blog.tooljet.com

Building a realtime multiplayer game using React & Conflict-free replicated data types (CRDT) of Yjs

TL;DR: In this article, you will learn how to build a real-time, collaborative Tic Tac Toe game application that enables two users to play the game simultaneously. To create this app, you will use React & Yjs.

We were trying to build realtime multiplayer editing capabilities into ToolJet (https://github.com/ToolJet/ToolJet/). Those who are not familiar with ToolJet, it is an open-source low-code application builder. We had tried different approaches for this but found Yjs to be the best approach. Hope this tutorial will help you get started with Yjs and CRDTs. If you want to see how capable is CRDTs, check out our PR for realtime multiplayer editing of applications in ToolJet.

Yjs is a CRDT implementation that exposes its internal data structure as shared types which we will be using to build a real time collaborative game tic tac toe.

Want to jump straight to the code? here it is: https://github.com/ToolJet/yjs-crdt-game


Building the UI

Let's first start by building the 3-by-3 grid user interface for the game and then add reactivity to it

Let's start by creating our parent component <Game />,

<div className="game">
    <div className="game-board">
        ...
    </div>
    <div className="game-info">
        <div>{status}</div>
        <ol>{moves}</ol>
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

The game component does the following:

  1. Shows the status of the game i.e. displays the winner or displays who has to play the next turn
  2. Allows a user to reset the game from the beginning
  3. Show a list of moves performed
  4. Renders the game board (explained in the next section)

<Board /> component will look like this:

<div className="game-board">
   <Board
      squares={current.squares}
      onClick={i => this.handleClick(i)}
    />
</div>
Enter fullscreen mode Exit fullscreen mode

The board component renders squares that we need to display for user input:

class Board extends React.Component {
  renderSquare(i) {
    return (
      <Square
        value={this.props.squares[i]}
        onClick={() => this.props.onClick(i)}
      />
    );
  }

  render() {
    return (
      <div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

<Square /> component is a simple functional component that renders a square:

function Square(props) {
  return (
    <button className="square" onClick={props.onClick}>
      {props.value}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode
.square {
  background: #fff;
  border: 1px solid #999;
  float: left;
  font-size: 24px;
  font-weight: bold;
  line-height: 34px;
  height: 34px;
  margin-right: -1px;
  margin-top: -1px;
  padding: 0;
  text-align: center;
  width: 34px;
}
Enter fullscreen mode Exit fullscreen mode

After adding all these components our UI should look like the following:

UI


Adding the game logic

Let's start by adding the initial state of the application inside the <Game /> component:

this.state = {
    history: [
        {
            squares: Array(9).fill(null),
        },
    ],
    stepNumber: 0,
    xIsNext: true,
};
Enter fullscreen mode Exit fullscreen mode

Initially, all nine square are neither filled with "X" or "O" hence we are storing the array with nine null values, initialising the step with count 0 and allowing "X" to be the first one to make the move.

In the game of tic tac toe, a winner is decided whenever the player succeeds in placing three of their marks in a horizontal, vertical, or diagonal row is the winner.

Let's convert this into code:

function calculateWinner(squares) {
  const lines = [
    [0, 1, 2], // horizontal
    [3, 4, 5], // horizontal
    [6, 7, 8], // horizontal
    [0, 3, 6], // vertical
    [1, 4, 7], // vertical
    [2, 5, 8], // vertical
    [0, 4, 8], // diagonal
    [2, 4, 6], // diagonal
  ];
  for (let i = 0; i < lines.length; i++) {
    const [a, b, c] = lines[i];
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return squares[a];
    }
  }
  return null;
}
Enter fullscreen mode Exit fullscreen mode

In the above function we pass a variable called squares that we had declared in our <Game /> component. Each square[i] contains either 'X' or 'O'. In the above for loop we check if the three consecutive values in either horizontal, vertical or diagonal contain the same value i.e. either X or O. If its true then 'X' or 'O' is returned as the winner.


Making the game collaborative

Let's add yjs to the code for allowing two users to be able to play the game collaboratively.

For this purpose we are going to use two packages yjs and y-webrtc.

const ydoc = new Y.Doc();
const provider = new WebrtcProvider(`tutorial-tic-tac-toe`, ydoc);
Enter fullscreen mode Exit fullscreen mode

To start of with we create a ydoc which represents a yjs document. This document is passed to a webrtc provider that helps us utilize public signalling servers for creating a peer-to-peer connection.

  componentDidMount() {
    const ymap = ydoc.getMap('state');
    ymap.observe(() => {
      this.setState({
        ...ymap.get('state')
      })
    });
  }
Enter fullscreen mode Exit fullscreen mode

In the componentDidMount function we are going to declare a variable ymap, which gets a shared data type Y.Map called 'state'.

Then on the shared data type we add an observable to observe the changes on the state.

handleClick(i) {
    const history = this.state.history.slice(0, this.state.stepNumber + 1);
    const current = history[history.length - 1];
    const squares = current.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState(
      {
        history: history.concat([
          {
            squares: squares,
          },
        ]),
        stepNumber: history.length,
        xIsNext: !this.state.xIsNext,
      },
      () => {
        const ymap = ydoc.getMap('state');
        ymap.set('state', this.state);
      }
    );
  }
Enter fullscreen mode Exit fullscreen mode

Whenever a use clicks on any square, we use ymap.set(..) function to set a key value on the Y.map shared data type.

Now whenever we have a change on the ydoc the observable is called and all peers connected to the room through webrtc signalling server will receive the update.

The final outcome looks like below:

You can find the code at the GitHub repo https://github.com/ToolJet/yjs-crdt-game

Top comments (0)