Loading...

Tutorial

Converting a Sudoku app into a collaborative game

In this tutorial, we will show how to convert a single-player Sudoku app into a collaborative game, using the Concordant platform.
One, two or more users can play together on the same grid. When one player fills a square, the others observe the update. If two players fill the same square concurrently, both updates are retained. Later, one of them or another player can correct it, by assigning it a new value. Users can disconnect and work in isolation; when they reconnect, their updates are merged into the shared grid. Switching between connected/disconnected modes is seamless: the application continues to work without a hitch, and without any loss of data.
You can check a live demo of the multiplayer Sudoku game we created on our website.

Requirements

To run the single-player Sudoku, you will need Node v14, TypeScript and React installed.

The single-player Sudoku

Our single-user Sudoku leverages React. Here is a great tutorial for learning more about React.
As React is a component-based architecture, we build our Sudoku from three main components:
  1. The Game component manages the game logic.
  2. The Grid component manages the display of the game.
  3. The Cell component represents a grid cell.
The code for the single-player Sudoku is accessible on Github.
To test it, you have to install all dependencies and run it using :
$ npm install
$ npm run
Let's explain the basics of the single-player Sudoku first. If you prefer to go straight to how we created a Concordant based multiplayer version of the game, you can skip directly to the next part.

The Game component

The Game component manages the game logic. It state is composed of an array of two elements:
  • value, a string that contains the cell's value,
  • modifiable, a boolean, indicating if the cell is modifiable (not pre-filled),
Values are initializes when the composent is mounted.
interface IGameState {
  cells: { value: string; modifiable: boolean }[];
}

class Game extends React.Component<Record<string, unknown>, IGameState> {
  constructor(props: Record<string, unknown>) {
    super(props);
    const cells = new Array(81)
      .fill(null)
      .map(() => ({ value: "", modifiable: false}));
    this.state = {
      cells: cells
    };
  }

  componentDidMount(): void {
    this.initFrom(generateStaticGrid());
  }

  initFrom(values: string): void {
    assert.ok(values.length === 81);
    const cells = this.state.cells;
    for (let index = 0; index < 81; index++) {
      cells[index].value = values[index] === "." ? "" : values[index];
      cells[index].modifiable = values[index] === "." ? true : false;
    }
    this.setState({ cells: cells });
  }
}
The rendering is done by calling the Grid component and passing it the values and a function to pull up the changes. There is also a reset button that simply clears the values that are modifiable.
reset(): void {
  const cells = this.state.cells;
  for (let index = 0; index < 81; index++) {
    if (cells[index].modifiable) {
      cells[index].value = "";
    }
  }
  this.setState({ cells: cells });
}

handleChange(index: number, value: string): void {
  assert.ok(value === "" || (Number(value) >= 1 && Number(value) <= 9));
  assert.ok(index >= 0 && index < 81);
  if (!this.state.cells[index].modifiable) {
    console.error(
      "Trying to change an non modifiable cell. Should not happend"
    );
  }

  const cells = this.state.cells;
  cells[index].value = value;
  this.setState({ cells: cells });
}

render(): JSX.Element {
  return (
    <div className="sudoku">
      <div>
        <button onClick={this.reset.bind(this)}>Reset</button>
      </div>
      <br />
      <Grid
        cells={this.state.cells}
        onChange={(index: number, value: string) =>
          this.handleChange(index, value)
        }
      />
    </div>  );
}

The Grid component

The Grid component manages the display of the game.
It displays the values passed by the parent as a prop and checks whether the current grid is valid according to the Sudoku rules, and whether the grid is complete. If a cell contains a wrong value, the corresponding cell displays in a different colour.
To render the grid, we use an HTML table of Cells, with a modification handler, passed as a parameter. Updates are "lifted up" to their parent through onChange prop.
interface IGridProps {
  cells: { value: string; modifiable: boolean }[];
  onChange: (index: number, value: string) => void;
}

class Grid extends React.Component<IGridProps> {
  errors: boolean[] = new Array(81).fill(false);

  handleChange(index: number, value: string): void {
    this.props.onChange(index, value);
  }

  renderCell(index: number): JSX.Element {
    assert.ok(index >= 0 && index < 81);
    return (
        <Cell
          index={index}
          value={this.props.cells[index].value}
          onChange={
            this.props.cells[index].modifiable
              ? (index: number, value: string) => this.handleChange(index, value)
              : null
          }
          error={this.errors[index]}
        />    );
  }

  renderBlock(blockNum: number): JSX.Element {
    assert.ok(blockNum >= 0 && blockNum < 9);
    const index = cellsIndexOfBlock(blockNum);
    return (
      <td>
          {this.renderCell(index[0])}
          {this.renderCell(index[1])}
          {this.renderCell(index[2])}
          <br />
          {this.renderCell(index[3])}
          {this.renderCell(index[4])}
          {this.renderCell(index[5])}
          <br />
          {this.renderCell(index[6])}
          {this.renderCell(index[7])}
          {this.renderCell(index[8])}
        </td>    )
  }

  render(): JSX.Element {
    const isFinished = this.checkAll();
    return (
      <div>
          <table className="grid">
            <tbody>
              {[0, 1, 2].map((line) => (
                <tr key={line.toString()}>
                  {this.renderBlock(line * 3)}
                  {this.renderBlock(line * 3 + 1)}
                  {this.renderBlock(line * 3 + 2)}
                </tr>
              ))}
            </tbody>
          </table>
          {isFinished && (
            <h2 className="status" id="status">
              Sudoku completed
            </h2>
          )}
        </div>    );
  }
}

The Cell component

The Cell component is an input element, representing a single cell. It contains:
  • index, a number that represents the index of the cell in the grid,
  • value, the current value of the cell,
  • onChange, the handler that will be called when the value is changed,
  • error, a boolean that indicates if the value is in conflict with another cell.
interface ICellProps {
  index: number;
  value: string;
  onChange: ((index: number, value: string) => void) | null;
  error: boolean;
}
When the value of a cell is modified, onChange checks the new value, before forwarding it to the grid.
onChange(event: React.ChangeEvent): void {
  if (this.props.onChange === null) {
    return;
  }
  if (event.target.value === "" || validInput.test(event.target.value)) {
    this.props.onChange(this.props.index, event.target.value);
  }
}

render(): JSX.Element {
  let cellClass = "";
  if (this.props.value.length > 1) {
    cellClass += "mv ";
  }
  if (this.props.onChange === null) {
    cellClass += "locked ";
  }
  if (this.props.error) {
    cellClass += "wrongvalue ";
  }
  return (
    <input
      id={String(this.props.index)}
      className={cellClass}
      maxLength={1}
      value={this.props.value}
      onChange={(event) => this.onChange(event)}
      readOnly={this.props.onChange === null}
    />  );
}
Great, the single-player Sudoku works! Check the code for more details and comments.

Making Sudoku collaborative, replicated, and persistent

Now let's move on to the interesting part.
In this section, we will discuss how we transformed the single-player Sudoku described above into a multi-player game, using our Concordant platform. It will support multiple players and long-running sessions, because Concordant manages concurrent updates and data persistence.

The Concordant platform

Let's focus on the three main parts of the Concordant platform: the Concordant CRDT library, the Concordant Service, and the Concordant Client library.
We'll start with the Service, dubbed C-Service. It currently requires you to install CouchDB and to run a CouchDB instance. A quick and easy way is to use a Docker container with port forwarding:
$ docker run -d --name couchdb -p 5984:5984 -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password couchdb
Alternatively take a look at the README.md file of C-Service.
Once CouchDB is running, you will need to provide CouchDB credentials for the Service:
$ export COUCHDB_USER=admin COUCHDB_PASSWORD=password
Then run the C-Service with:
$ npx @concordant/c-service
The C-Service runs remotely, co-located with the CouchDB server. You can run a local C-Service with a service worker, leveraging a local instance of PouchDB. To run the service worker, you must first download the code of the C-Service on our Gitlab. This service worker is configured to use our demo database, so feel free to re-build it to use your own: adjust the configuration in `src/config.json` and run
npm install
The service worker is bundled in `dist/c-service-worker.js`. To use it, just copy (or link) it to the public repository of the application i.e. public/c-service-worker.js.
The application interacts with the C-Service via the C-Client client library. Install the NPM package:
$ npm i @concordant/c-client
You can find more information about Concordant platform on our software page.

Updating the Game component

An application accesses Concordant via the C-Service interface. To do this, let's first import C-Client into the top-level component of the Sudoku app, Game:
import { client } from '@concordant/c-client';
The app opens a session, which is the context for interacting with the Concordant database engine (database information is passed through a configuration file). Then, the app can access its data stored in a "collection" (a set of related objects). Now let's open the collection containing the data for our grid (oh, and remember to close it when done!).
import CONFIG from "../config.json";

const session = client.Session.Companion.connect(
  CONFIG.dbName,
  CONFIG.serviceUrl,
  CONFIG.credentials
);

const collection = session.openCollection("sudoku", false);
Let's define the React State to put data into an MVMap. For this, let's open the MVMap object. Here, the MVMap is called "grid", we open it in read/write mode (read-only = false) and with a handler that we will see later. The handler will be called when a new remote update of the object arrives.
Note: Currently, the MVMap is declared as type any, because the TypeScript definition file is incomplete. This issue will be fixed in a future version.
interface IGameState {
  mvmap: any;
  cells: { value: string; modifiable: boolean }[];
}

constructor(props: Record) {
  super(props);
  const cells = new Array(81)
    .fill(null)
    .map(() => ({ value: "", modifiable: false }));
  const mvmap = collection.open("grid", "MVMap", false, this.handler.bind(this));
  this.state = {
    mvmap: mvmap,
    cells: cells
  };
}
As the app uses the MVmap to store its state, updating it when a user types in a new cell value, the state is automatically shared, replicated, and persistent.

It is important to remember that the app operates on objects within a transaction, i.e., a block of related accesses. You are not allowed to operate on objects outside of a transaction. Note: Concordant will eventually support a choice of transaction consistency levels (a.k.a. isolation levels). However, in the currently available alpha version, we only implement None, i.e., a transaction is not guaranteed to be atomic and cannot abort.

The app handlers for reset and handleChange should update the MVmap accordingly.
reset(): void {
  const cells = this.state.cells;
  session.transaction(client.utils.ConsistencyLevel.None, () => {
    for (let index = 0; index < 81; index++) {
      if (cells[index].modifiable) {
        cells[index].value = "";
        this.state.mvmap.setString(index, cells[index].value);
      }
    }
  });
  this.setState({ cells: cells });
}

handleChange(index: number, value: string): void {
  assert.ok(value === "" || (Number(value) >= 1 && Number(value) <= 9));
  assert.ok(index >= 0 && index < 81);
  if (!this.state.cells[index].modifiable) {
    console.error("Trying to change an non modifiable cell. Should not happend");
  }

  const cells = this.state.cells;
  cells[index].value = value;
  this.setState({ cells: cells });

  session.transaction(client.utils.ConsistencyLevel.None, () => {
    this.state.mvmap.setString(index, value);
  });
}
As explained earlier, when multiple users update a MVmap key, all the concurrent values are retained. This means that the value returned for a key is not a single scalar, but a Set. We need to concatenate all the elements of this set into a single string. We call it hashSetToString.
function hashSetToString(set: any): string {
  const res = new Set();
  const it = set.iterator();
  while (it.hasNext()) {
    const val = it.next();
    if (val !== "") {
      res.add(val);
    }
  }
  return Array.from(res).sort().join(" ");
}
Concordant uses notifications to keep up to date with remote updates. When a remote updates arrives, the client is notified by the ServiceWorker if available, otherwise via WebSocket. And the client notifies the application via the handler passed when opening the crdt. Then, the application must call the pull method of the collection explicitly to merge the received remote update.
componentDidMount(): void {
  this.initFrom(generateStaticGrid());
}

private handler() {
  this.pullGrid()
}

pullGrid(): void {
  const cells = this.state.cells;
  collection.pull(client.utils.ConsistencyLevel.None);
  for (let index = 0; index < 81; index++) {
    if (cells[index].modifiable) {
      cells[index].value = "";
    }
  }
  session.transaction(client.utils.ConsistencyLevel.None, () => {
    const itString = this.state.mvmap.iteratorString();
    while (itString.hasNext()) {
      const val = itString.next();
      cells[val.first].value = hashSetToString(val.second);
    }
  });
  this.setState({ cells: cells });
}

The Grid and Cell component: no changes.

A relief: there is no need to modify Grid and Cell ;-)

Take-aways

That's it, you can now play Sudoku with your friends. Just run it using :
$ npm run
If two users write into the same cell concurrently, the multi-value (MV) policy retains all values. Users get to observe all the concurrent values. They can now assign a new (single) value to reconcile the conflict.
The full code (in alpha version) is available on Github. This version has several features that we omitted to describe in this tutorial. For example, a player can change grids, can reset the grid, or can disconnect from other players. If service worker is available on your browser, you can work truly disconnected. Otherwise, you still relies on a remote server, in this case, you can use the Disconnect that simulates disconnection. In simulated disconnection mode, the app does not apply remote updates, and logs its local updates. When reconnecting, it synchronizes them with remote players.
Using Concordant allowed us to quickly and easily adapt a single-player game into a fully collaborative, replicated and consistent multi-player game. Why not try with your application and let us know the results. If you need help, have any questions or really cool user-case applications, please do not hesitate to contact us at support@concordant.io.