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: Game, Grid and Cell.
The Game component contains text and the Sudoku Grid. The Grid component contains cell values, and checks them for validity. The Cell component is an HTML input element.
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 simply displays the embedded Grid.
class Game extends React.Component<{}, {}> {
    render() {
        return (
            <Grid />
        );
    }
}

The Grid component

The Grid component displays the collection of cells. A cell has a value. Cells that are pre-filled are not modifiable. This component checks whether the current grid is valid according to the Sudoku rules, and whether the grid is complete.
Thus, the Grid contains an array, where each element is composed of three values:
  • value, a string that contains the cell's value,
  • modifiable, a boolean, indicating if the cell is modifiable (not pre-filled),
  • error, a boolean that indicates if the value of this cell is in conflict with another one.
The Grid also stores a boolean, which indicates whether the grid is complete. When error is set, the corresponding cell displays in a different colour.
interface IGridState {
    cells: {value: string, modifiable: boolean, error: boolean}[],
    isFinished: boolean
}

class Grid extends React.Component<{}, IGridState> {
    constructor(props: any) {
        super(props);
        let cells = new Array(81).fill(null).map(()=>({value:"", modifiable:false, error:false}));
        this.state = {
            cells: cells,
            isFinished: false
        };
    }
}
Mounting the component initializes the grid.
componentDidMount() {
    this.initFrom(generateStaticGrid());
}
    
initFrom(values:any) {
    assert.ok(values.length === 81);
    let 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.updateState(cells) // Check where the errors are and update the component state
}
To render the grid, we use an HTML table of Cells, with a modification handler, passed as a parameter.
handleChange(index: number, value: string) {
    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 happen");
    }

    let cells = this.state.cells;
    cells[index].value = value;
    this.updateState(cells);
}

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

renderBlock(blockNum: number) {
    assert.ok(blockNum >= 0 && blockNum < 9)
    let index = blockIndex(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() {
    return (
        <div className="sudoku">
            <div><button onClick={this.reset.bind(this)}>Reset</button></div><br />
            <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>
            {this.state.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: any,
    error: boolean
}
When the value of a cell is modified, onChange checks the new value, before forwarding it to the grid.
onChange(event: any) {
    if (event.target.value === "" || validInput.test(event.target.value)) {
        this.props.onChange(this.props.index, event.target.value)
    } else {
        console.error("Invalid input in cell " + this.props.index + " : " + event.target.value)
    }
}

render() {
    let cellClass = ""
    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={this.props.onChange ? (event) => this.onChange(event) : function() { }}
            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 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.
Note: The C-Service currently runs remotely, co-located with the CouchDB server. In future versions, it will also be available as a local service worker, leveraging a local instance of PouchDB.

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!).
interface IGameState {
    session: any,
    collection: any
}

class Game extends React.Component<{}, IGameState> {
    constructor(props: any) {
        super(props);
        let CONFIG = require('../config.json');
        let session = client.Session.Companion.connect(CONFIG.dbName, CONFIG.serviceUrl, CONFIG.credentials);
        let collection = session.openCollection("sudoku", false);
        this.state = {
            session: session,
            collection: collection
        }
    }

    render() {
        return (
            <Grid session={this.state.session} collection={this.state.collection} />
        );
    }
}

Updating the Grid component

Similarly, we import C-Client into Grid:
import { client } from '@concordant/c-client';
In order to pass the current session and collection to the Grid, we use its React Props.
interface IGridProps {
    session: any,
    collection: any
}

class Grid extends React.Component<IGridProps, IGridState> { ... }
Let's define the React State to put data into an MVMap. For this, let's open the MVMap object.
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 IGridState {
    mvmap: any,
    cells: {value: string, modifiable: boolean, error: boolean}[],
    isFinished: boolean
}

constructor(props: any) {
    super(props);
    let cells = new Array(81).fill(null).map(()=>({value:"", modifiable:false, error:false}));
    let mvmap = this.props.collection.open("grid", "MVMap", false, function () {return});
    this.state = {
        mvmap: mvmap,
        cells: cells,
        isFinished: false
    };
}
The app handlers for reset and handleChange should update the MVmap accordingly.
reset() {
    let cells = this.state.cells;
    for (let index = 0; index < 81; index++) {
        if (cells[index].modifiable) {
            cells[index].value = "";
            this.props.session.transaction(client.utils.ConsistencyLevel.None, () => {
                this.state.mvmap.setString(index, cells[index].value);
            })
        }
    }
    this.updateState(cells)
}

handleChange(index: number, value: string) {
    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");
    }

    let cells = this.state.cells;
    cells[index].value = value;
    this.updateState(cells);

    this.props.session.transaction(client.utils.ConsistencyLevel.None, () => {
        this.state.mvmap.setString(index, value);
    })
}
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.

this.props.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) {
    let res = new Set();
    let it = set.iterator();
    while (it.hasNext()) {
        let val = it.next();
        if (val !== "") {
            res.add(val);
        }
    }
    return Array.from(res).sort().join(' ')
}
The currently available alpha version of Concordant doesn't have notifications; therefore, we must use a timer, to poll for remote updates.
timerID!: NodeJS.Timeout;

componentDidMount() {
    this.initFrom(generateStaticGrid());
    this.timerID = setInterval(
        () => this.updateGrid(),
        1000
    );
}

componentWillUnmount() {
    clearInterval(this.timerID);
}

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

The Cell component: no changes.

A relief: there is no need to modify 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. As the currently available version of Concordant relies on a remote server, the Disconnect simulates disconnection. In simulated disconnection mode, the app does not poll for 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.