Creating a puzzle game with ReactJs + MongoDB Atlas + GCP Cloud Run
Tags: atlashackathon22, react, mongodb, cloudrun
Published: 2022-12-08 23:53:59

## What I built
GoldRoad is a daily coins puzzle game in a browser. Your goal is to find the best possible path between two given coins. The best path is the one which gives you the maximum gold.

### Category Submission:
Choose Your Own Adventure

Though the app will be using the real time aspect of atlas change streams, but that is still in progress. Also it uses Google Cloud Run for hosting, but no other Google API has been used. So choose your own adventure seems the correct choice.

### App Link
The game can be tried out [here](https://goldroad.web.app)

### Screenshots

#### The game page
![GoldRoad games page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1vs85jqfraoux3kitvku.png)

#### The about page

![GoldRoad about page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/p5hxolvkc8q2apscx1t2.png)

### Description
GoldRoad is a daily coins puzzle game in a browser. The objective of the game is to find the best possible path between two given coins. The best possible path is the one which allows the user to collect maximum gold coins.

#### Current Features
- Unlimited plays of the daily game
- Auto game refresh with a new game at 12:00 AM UTC
- Auto creation of new games from the backend

### Link to Source Code
{% embed https://github.com/ra-jeev/goldroad %}

### Permissive License
The source is licensed under MIT License.

## Background
Some months ago I had come across [one twitter post](https://twitter.com/sumul/status/1545430273113866240) announcing a new daily puzzle game. I gave it a try and liked the game so much that I implemented the same using Python and [published blogs](https://dev.to/ra_jeeves/series/20571) documenting the process. 

From then only, I wanted to create a similar, but different game. Recently I was watching a video explaining the greedy algorithm for finding the best path, and that instantly gelled with the Figure game in my mind. So here it is, after some back-and-forth with different approaches and customizations. 

### How I built it
I came to know about the hackathon quite late. So it would have been easier to stick to my comfort zone, and use VueJs together with Firebase (of course Firestore/RealtimeDb wasn't an option considering you're supposed to use MongoDB). But what is the point of doing that? The spirit of hackathon is to use the given technology as much as possible (at least that is what I believe).

So, I created an account with MongoDB (first time :-)).

As soon as you create an account, it asks you to create a cluster, I used the below settings (Great to have Termination Protection available for Free Shared Clusters also).

![Mongo cluster creation image 1](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/la3lbuf8r57hh0ti4hhf.png)


![Mongo cluster creation image 2](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/g31l9yluvlz68s31z6py.png)

During the process you can also create a database as well as a project. Since I needed to interact with the project locally, I created an API Key by going to Access Manager.

#### Local Setup
First of all, we need to install `realm-cli` globally using
```
npm install -g mongodb-realm-cli
```

then authenticate using 
```
realm-cli login --api-key="<my api key>" --private-api-key="<my private api key>"
```

Now we're ready to interact with our backend using CLI.

Create a local react project using
```
yarn create react-app my-app
```

In my case I moved all of the files to an inner folder called frontend. Then I created a backend folder inside the root directory and created a new backend application using

```
realm-cli app create --environment development --cluster <my_cluster_name>
```

At this point of time the project folder looks something like below:

![Project structure screenshot](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9c9o8i35za3izzi2ajxd.png)

#### App Services HTTPS Endpoints
After local project creation the first task at hand was to be able to create new games, and store them in the DB. I needed a backend worker to execute my already tested local game generation code. This backend worker needed to support 2 approaches
1. Ability to be called ad-hoc (so that I can readily create new games in case of emergencies)
2. Ability to be called automatically through some trigger (so that I need not call it ad-hoc :-))

HTTPS Endpoints backed by functions fit the bill perfectly. Didn't find a way to create an endpoint interactively using the realm-cli, so created the same using the app services UI (Go inside your application in the App Services tab, and pick HTTPS Endpoints from the left sidebar menu).

While creating an endpoint you can configure the Authentication for the function backing this endpoint (which is by default Application Auth). Since I will be calling this endpoint ad-hoc (using Postman and such), I had to change the authentication to `System`. This is not possible to do while creating the endpoint. We can do it afterwards by going to the backing function settings, and changing the authentication to `System`.

To give our endpoint a bit of protection, I changed the endpoint authorization to `Verify Payload Signature`. By doing so, to make a successful call to our endpoint we need to sign our payload with a secret (which we create in the app services UI itself).

Since we didn't have any function till now, I created a new function from the UI itself, and accepted the default generated code.

Now we're ready to test this endpoint. Launched postman, configured the endpoint URL, selected the correct method (POST in my case), add some dummy body, and BAM! `Send`. As expected we get an error

```json
{
    "error": "expected to find Endpoint-Signature in header",
    "error_code": "InvalidParameter",
    "link": "https://realm.mongodb.com/groups/6385ddf6b41c43346bccf9ff/..."
}
```

We need to sign our payload using the secret key. To do so using Postman, we can add the following code in the `Pre-request Script` tab below the URL address bar
```js
const signBytes = CryptoJS.HmacSHA256(pm.request.body.raw, '<my_secret_code>');
const signHex = CryptoJS.enc.Hex.stringify(signBytes);
pm.request.headers.add({
    key: "Endpoint-Signature",
    value: "sha256="+signHex
});
```

I struggled with this for some time, the reason being the endpoint creation UI itself. It shows the below example

```
curl \
-H "Content-Type: application/json" \
-d '{"foo":"bar"}' \
-H 'X-Hook-Signature:sha256=<hex-encoded-hash>' \
https://data.mongodb-api.com/app/<my_app_id>/endpoint/<endpoint_route>
```
So I was trying with `X-Hook-Signature`, but the endpoint needed `Endpoint-Signature` as is evident from the error we got earlier. I also needed some time to figure out the "sha256=" prefix for the header value. But all's well that ends well :-).

Here the function code which generates a new game, find out its solution and stores the same in a DB collection called games.

```js
const ROWS = 6;
const COLS = 6;

// Generate a random number between min (included) & max (excluded)
const randomInt = (min, max) => {
  return Math.floor(Math.random() * (max - min)) + min;
};

const getCoinsWithWalls = (start, end, count) => {
  const coinColIndices = [];
  while (coinColIndices.length < count) {
    const index = randomInt(start, end);
    if (!coinColIndices.includes(index)) {
      coinColIndices.push(index);
    }
  }

  return coinColIndices;
};

const addJob = (jobs, src, currJob) => {
  jobs.push({
    coins: JSON.parse(JSON.stringify(currJob.coins)),
    src,
    dst: currJob.dst,
    pastMoves: JSON.parse(JSON.stringify(currJob.pastMoves)),
    total: currJob.total,
  });
};

const handleJob = (jobs, job) => {
  const row = job.src[0];
  const col = job.src[1];
  const srcNode = job.coins[row][col];

  srcNode.finished = true;
  if (row === job.dst[0] && col === job.dst[1]) {
    job.total += srcNode.value;
    job.pastMoves.push(`${job.dst[0]}${job.dst[1]}`);
    return true;
  }

  const neighbors = {
    prevNode: col > 0 ? job.coins[row][col - 1] : null,
    nextNode: col < COLS - 1 ? job.coins[row][col + 1] : null,
    topNode: row > 0 ? job.coins[row - 1][col] : null,
    bottomNode: row < ROWS - 1 ? job.coins[row + 1][col] : null,
  };

  job.total += srcNode.value;
  job.pastMoves.push(srcNode.id);

  for (const key in neighbors) {
    const neighbor = neighbors[key];
    if (neighbor && !neighbor.finished) {
      if (key === 'prevNode' && neighbor.wall !== 2 && srcNode.wall !== 4) {
        addJob(jobs, [row, col - 1], job);
      }

      if (key === 'nextNode' && neighbor.wall !== 4 && srcNode.wall !== 2) {
        addJob(jobs, [row, col + 1], job);
      }

      if (key === 'topNode' && neighbor.wall !== 3 && srcNode.wall !== 1) {
        addJob(jobs, [row - 1, col], job);
      }

      if (key === 'bottomNode' && neighbor.wall !== 1 && srcNode.wall !== 3) {
        addJob(jobs, [row + 1, col], job);
      }
    }
  }

  return false;
};

const findBestRoute = (coins, start, end) => {
  const src = [parseInt(start[0]), parseInt(start[1])];
  const dst = [parseInt(end[0]), parseInt(end[1])];

  const jobs = [{ coins, src, dst, pastMoves: [], total: 0 }];
  const results = [];

  while (jobs.length) {
    const job = jobs.shift();
    if (handleJob(jobs, job)) {
      results.push({
        total: job.total,
        moves: job.pastMoves.length,
        path: job.pastMoves,
      });
    }
  }

  if (results.length) {
    results.sort((result1, result2) => {
      return result2.total - result1.total;
    });

    return results[0];
  } else {
    console.log(`No valid path found`);
  }
};

exports = async function (req) {
  let reqBody = null;
  if (req) {
    if (req.body) {
      console.log(`got a req body: ${req.body.text()}`);
      reqBody = JSON.parse(req.body.text());
    } else {
      console.log(`got a req without req body: ${JSON.stringify(req)}`);
    }
  }

  const coins = [];
  for (let row = 0; row < ROWS; row++) {
    coins.push([]);

    const blockages = getCoinsWithWalls(0, COLS, 2);
    for (let col = 0; col < COLS; col++) {
      const coin = {
        id: `${row}${col}`,
        value: randomInt(1, 7),
        wall: 0,
      };

      if (blockages.includes(col)) {
        coin.wall = randomInt(1, 5);
      }

      coins[row].push(coin);
    }
  }

  const start = `${randomInt(2, 4)}${randomInt(2, 4)}`;
  let end = randomInt(1, 5);
  if (end === 1) {
    end = '00';
  } else if (end === 2) {
    end = `0${COLS - 1}`;
  } else if (end === 3) {
    end = `${ROWS - 1}0`;
  } else {
    end = `${ROWS - 1}${COLS - 1}`;
  }

  const date = new Date();
  const gameEntry = {
    coins,
    start,
    end,
    active: false,
    createdAt: date,
    updatedAt: date,
  };

  const startTime = Date.now();
  const bestMove = findBestRoute(JSON.parse(JSON.stringify(coins)), start, end);
  console.log(
    `Total time taken for finding bestRoute: ${Date.now() - startTime} ms`
  );

  if (bestMove) {
    console.log(`best path: ${JSON.stringify(bestMove)}`);
    gameEntry.maxScore = bestMove.total;
    gameEntry.maxScoreMoves = bestMove.moves;
    gameEntry.hints = bestMove.path;

    const mongoDb = context.services.get('gcp-goldroad').db('goldroadDb');
    const gamesCollection = mongoDb.collection('games');
    const appCollection = mongoDb.collection('app');
    const config = await appCollection.findOne({ type: 'config' });

    console.log('fetch config data:', JSON.stringify(config));
    console.log('lastPlayableGame:', config.lastPlayableGame);

    if (config) {
      if (config.lastPlayableGame) {
        const lastPlayableDate = config.lastPlayableGame.playableAt;
        lastPlayableDate.setUTCDate(lastPlayableDate.getDate() + 1);
        gameEntry.playableAt = lastPlayableDate;
        gameEntry.gameNo = config.lastPlayableGame.gameNo + 1;
        if (reqBody) {
          if (reqBody.active) {
            gameEntry.active = true;
          }

          if (reqBody.current) {
            gameEntry.current = true;
          }
        }
      } else {
        const playableDate = new Date();
        playableDate.setUTCHours(0, 0, 0, 0);
        gameEntry.playableAt = playableDate;
        gameEntry.gameNo = 1;
        gameEntry.current = true;
        gameEntry.active = true;
      }
    }

    let result = await gamesCollection.insertOne(gameEntry);
    console.log(
      `Successfully inserted game with _id: ${JSON.stringify(result)}`
    );

    result = await appCollection.updateOne(
      { type: 'config' },
      {
        $set: {
          lastPlayableGame: {
            playableAt: gameEntry.playableAt,
            gameNo: gameEntry.gameNo,
            _id: result.insertedId,
          },
        },
      }
    );

    console.log('result of update operation: ', JSON.stringify(result));
  }

  return gameEntry;
};
```

I edited the above code locally (in the `functions` folder of the backend folder). Anytime you're making a change in the App Services UI, you need to pull the changes to your local codebase by running

```
realm-cli pull
```
while we need to push the changes to the remote after doing any change locally

```
realm-cli push
```

In the above function also I struggled for quite some time due to 2 issues.
1. Functions documentation says that it supports `crypto` module partially. I was using `crypto.randomInt` to do my random number generation. But the function was failing giving unhelpful error messages (`TypeError: Value is not an object: undefined`). After a lot of trial and error, ultimately found out that `randomInt` method is unavailable in app services crypto, so created own function to generate random numbers. Would be really great to have some meaningful error messages.
2. I was using `Set` to keep track of generated walls (`blockages`). But on checking `if (blockages.has(col))` I was getting `false` for all columns except `0`. Again this needed some testing, and figuring out. So replaced Set with a list. Not sure if `Set` is supported (will check again later).

> **Update 1: 13 Dec 2022:** Checked again re: the Set issue and I've no explanation for the weird behavior. Set is supported for sure, but in my code it is not giving the desired output. If I test the same code in my local node setup, it works without any issue. As already stated, replacing the set with a list works perfectly fine on app services functions.

> **Update 2: 13 Dec 2022:** So I couldn't stop thinking about the issue, and raised it in the official MongoDB Developer Community Forum. Heard back from them with the below response

> _"Thank you for raising this: we could reproduce the behaviour, and indeed it looks like Set.has(…) isn’t returning the expected results.
> We’re opening an internal ticket about the matter, and will keep this post updated."_

> **Update 3 (Final Update): 14 Dec 2022:** Received a new reply from the mongo team that they've identified the bug, and it will be solved in due course (no timeframe). This concludes the mystery around the issue :-)

> _"The Team responsible of the underlying function engine has confirmed that the error is due to mishandling of integer values. Set.has(…) should still work for other types, though.
> (To clarify: the problem is that the addition of two integers is returning a float, that Set.has() doesn’t compare properly)"_
>
> Notice that we're doing addition while generating a random number `Math.floor(Math.random() * (max - min)) + min;`

#### Data access rules
To get data using the realm Web SDK (which I added to the react app), we need to configure the data access rules for each of the collections we create. We can do so easily using the App Services UI (or the JSON files in the local setup, but that takes some time getting familiar with). 

#### Scheduled Triggers
Since our puzzles should change everyday, we need a trigger to do so automatically. App services provide many types of triggers, for my purpose I needed a simple CRON schedule which runs everyday at 12:00 AM UTC. Used the advanced schedule type from the UI, and set the schedule to `0 0 * * *` and let it call another function to change the game.

Below is code of the function which makes the current game non-current, and makes the next game in line (recognized by the next `gameNo` field of the document).

```js
exports = async function () {
  const mongoDb = context.services.get('gcp-goldroad').db('goldroadDb');
  const gamesCollection = mongoDb.collection('games');
  const currGame = await gamesCollection.findOne({ current: true });
  if (currGame) {
    console.log('got the current game: ', JSON.stringify(currGame));
    const date = new Date();
    const nextGameDate = new Date();
    nextGameDate.setUTCHours(0, 0, 0, 0);
    nextGameDate.setUTCDate(nextGameDate.getDate() + 1);
    await gamesCollection.bulkWrite(
      [
        {
          updateOne: {
            filter: { gameNo: currGame.gameNo + 1 },
            update: {
              $set: {
                current: true,
                active: true,
                updatedAt: date,
                playedAt: date,
                nextGameAt: nextGameDate,
              },
            },
          },
        },
        {
          updateOne: {
            filter: { _id: currGame._id },
            update: { $set: { current: false, updatedAt: date } },
          },
        },
      ],
      { ordered: true }
    );

    console.log('after the bulkWrite Op');
  } else {
    console.log('Error! No current game found.');
  }
};
```

#### Database trigger
Since we're consuming the stored game documents everyday automatically, we also need to find a way to generate games the same way. This can be done using database triggers. Whenever we mark one of the games as the current game, we listen for the database trigger, and generate a new game by calling our old function.

![Database trigger screenshot](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3vo9ecyb7yvogti8q9sg.png)

Selected `Operation Type as Update`. Since on game refresh 2 documents are updated (one the current game, and the other the next game), used a match expression (Advanced Optional setting) to only trigger the function for the next game document. We're updating the `playedAt` field also on game refresh, so used that.

```json
{"updateDescription.updatedFields.playedAt":{"$exists":true}}
```
Somehow match expression with boolean field (`current`) was not working.
```json
{"updateDescription.updatedFields":{"current":true}}
```
Didn't try with the dot notation 
```json
{"updateDescription.updatedFields.current": true}
```

We could have created a new game in the scheduled trigger itself (where we refresh our daily puzzle), but there is an upper limit of `150 seconds` on any function run time. So database trigger made more sense as it will provide some extra buffer time to my game generation function.

#### Anonymous Authentication and Auth Trigger
We also want to store the users' play history. So enabled the Anonymous Auth from the UI. Also added an auth trigger so that we can save the newly created users to the DB.

Auth Trigger function code which gets trigged on new user creation.

```js
exports = async function (authEvent) {
  const { user, time } = authEvent;

  const mongoDb = context.services.get('gcp-goldroad').db('goldroadDb');
  const usersCollection = mongoDb.collection('users');
  const userData = { _id: user.id, ...user, createdAt: time, updatedAt: time };
  userData.data = {
    currStreak: 0,
    longestStreak: 0,
    isCurrLongestStreak: false,
    solves: 0,
    played: 0,
  };

  delete userData.id;
  const res = await usersCollection.insertOne(userData);
  console.log('result of user insert op: ', JSON.stringify(res));
};
```
Didn't use any other type of auth provider as I don't want the players to worry about login at this point of time.

### Google Cloud Run Hosting
I wanted to try out App Services hosting also, but apparently that is only available for paid accounts. Google Cloud to the rescue. Utilized Google Cloud Run to host the application frontend.

For this needed to create a `Dockerfile` wit the below content in the frontend root folder (wherever the frontend package.json is)

```
FROM node:lts-alpine as react-build
WORKDIR /app
COPY . ./
RUN yarn
RUN yarn build

# server environment
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/configfile.template

COPY --from=react-build /app/build /usr/share/nginx/html

ENV PORT 8080
ENV HOST 0.0.0.0
EXPOSE 8080
CMD sh -c "envsubst '\$PORT' < /etc/nginx/conf.d/configfile.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'"
```

Also created an `nginx.conf` file in the frontend root folder

```
server {
     listen       $PORT;
     server_name  localhost;

     location / {
         root   /usr/share/nginx/html;
         index  index.html index.htm;
         try_files $uri /index.html;
     }

     gzip on;
     gzip_vary on;
     gzip_min_length 10240;
     gzip_proxied expired no-cache no-store private auth;
     gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml;
     gzip_disable "MSIE [1-6]\.";

}
```

Then build the project in a docker container using (Do remember to create a Google Cloud project, and enable Cloud Run API, Google Container Registry API & Cloud Build API for that project)

```
gcloud builds submit --tag gcr.io/<project_id>/app
```

Once the build is successful, we can deploy the application by running

```
gcloud run deploy --image gcr.io/<project_id>/app --platform managed
```
And voila, we can visit our frontend by going to the service URL as mentioned in the console.

### Further enhancements
1. Frontend code for the gameplay needs some refactoring
2. Game stats and analytics needs to be added
3. Current user's game history is getting stored in DB (needs further testing) but it is not getting displayed anywhere. Need to create another app route for the same
4. Caching has not been used anywhere. Maybe we can utilize Firebase hosting for application caching, as well as for caching the current game data
5. For user game play related interactions with the DB, need to use change streams / watch.
6. Update the user if a new game is available while they're using the app

### Conclusion
Overall it was a very good experience building the game with MongoDB Atlas & App Services. We can definitely have improved docs, but I am happy to have utilized this time and got familiar with Atlas & App Services.

Hope you enjoyed reading the article, and will enjoy playing [the game](https://goldroad.web.app).