DEV Community

Cover image for Creating a puzzle game with ReactJs + MongoDB Atlas + GCP Cloud Run
Rajeev R. Sharma
Rajeev R. Sharma Subscriber

Posted on • Edited on

Creating a puzzle game with ReactJs + MongoDB Atlas + GCP Cloud Run

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

Screenshots

The game page

GoldRoad games page

The about page

GoldRoad about page

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

GitHub logo ra-jeev / goldroad

The repo for the goldroad puzzle game. Based on ReactJs, and Mongo Atlas App Services

goldroad

The repo for the goldroad puzzle game. This game was created as part of the MongoDb Atlas Hackathon with Dev.to.

Frontend

Frontend is using ReactJs with Vanilla CSS.

Backend

Backend is Mongo Atlas App Services with MongoDB.




Permissive License

The source is licensed under MIT License.

Background

Some months ago I had come across one twitter post 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 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

Mongo cluster creation image 2

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
Enter fullscreen mode Exit fullscreen mode

then authenticate using

realm-cli login --api-key="<my api key>" --private-api-key="<my private api key>"
Enter fullscreen mode Exit fullscreen mode

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

Create a local react project using

yarn create react-app my-app
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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

Project structure screenshot

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

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

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

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
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

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;
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

while we need to push the changes to the remote after doing any change locally

realm-cli push
Enter fullscreen mode Exit fullscreen mode

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).

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.');
  }
};
Enter fullscreen mode Exit fullscreen mode

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

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.

{"updateDescription.updatedFields.playedAt":{"$exists":true}}
Enter fullscreen mode Exit fullscreen mode

Somehow match expression with boolean field (current) was not working.

{"updateDescription.updatedFields":{"current":true}}
Enter fullscreen mode Exit fullscreen mode

Didn't try with the dot notation

{"updateDescription.updatedFields.current": true}
Enter fullscreen mode Exit fullscreen mode

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.

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));
};
Enter fullscreen mode Exit fullscreen mode

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;'"
Enter fullscreen mode Exit fullscreen mode

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]\.";

}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

gcloud run deploy --image gcr.io/<project_id>/app --platform managed
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (2)

Collapse
 
raibtoffoletto profile image
Raí B. Toffoletto

Great game! I loved it! and great article explaining its implementation 🎉

ps: I also found that math.random is more useful most of the time than other random number generators.

Collapse
 
ra_jeeves profile image
Rajeev R. Sharma

Thanks a lot for trying it out. Glad that you liked it :-)