Aidan Kaiser
Aidan Kaiser

Reputation: 521

Matching Users Together For a 1v1 Game Using a Method That Scales Node.js/Express/MongoDB

Recently I have been working on a game that automatically matches two users together to play against one another if they are both trying to join a game with the same parameters. This matching is not concurrent, meaning that both users do not have to be online at the same time to match them. I am using MongoDB to store the games, which have a schema that looks like this.

    {
        competitorID1: {type: String, unique: false, required: true},
        competitorID2: {type: String, unique: false, required: false, default: null},
        needsFill: {type: Boolean, unique: false, required: true, default: true},
        parameter: {type: Number, unique: false, required: true}
    }

Currently, this is what my controller function to create/join a game looks like.

exports.join = (req, res) => {
            Game.findOneAndUpdate({
                needsFill: true,
                parameter: req.body.parameter,
                competitorID1: {$ne: req.user._id}
            }, {
                // Add the second competitor
                needsFill: false,
                competitorID2: req.user._id,
        }).then(contest => {
             if(contest) {
                 // Game was found and updated to include two competitors
             }
             else {
                 // Game with parameter was not found. Create a new game with needsFill set to true and only the competitorID1 populated
             }
        });
}

My question is: will this scale to a large number of users concurrently using the system and trying to join a game? My understanding is that the

findOneAndUpdate

function offered by mongoose is atomic, and therefore prevents concurrent modification by multiple requests. Forgive me if my assumptions are not good, I'm still trying to find my footing using MongoDB. Even if this does work correctly, please let me know if there is a better way to accomplish my goal. Thank you so much in advance for any help!

Upvotes: 2

Views: 243

Answers (2)

6be709c0
6be709c0

Reputation: 8441

The short response will be:
yes, it will scale because as you said, you are using an atomic operation.

However, how to ensure that the performance stays the same if you have 200 users or 200 millions. Let's have a look on differents aspect of your app:

  1. Logic Flow
  2. Schema
  3. Security
  4. Request
  5. Indexes
  6. Scalability

1. Logic Flow

  • I am a user and I want to start a game with an opponnent.
  • The server will try to find a game where I am not playing and either:
    • Create a game and wait for an opponnent
    • Add me in an existing game as an oponnent
  • Then you have your own logic to do something.

2. Schema

I believe that simplicity is key, so if you're doing a 1v1 game >

{
    status: {type: String, unique: false, required: true},
    playerId: {type: String, unique: false, required: true},
    opponentId: {type: String, unique: false, required: false, default: null},
    gameType: {type: Number, unique: false, required: true},
}
  • I replaced the needsFill with status field because it will give you elasticity, detail in the 4.requests below.
  • I modified the naming as well. I believe that if you want to have a game with your logic with more users, you will use an array of players instead of competitorID1, competitorID2, ...
  • I renamed parameter to gameType because the parameter seems too large for a name and we don't really understand the meaning of it, I assume you meant the type of the game.

Also, you can implement a schema validation in mongo to maintain your data consistency https://docs.mongodb.com/manual/core/schema-validation/

db.runCommand({
  collMod: "game",
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["status", "playerId", "gameType"],
      properties: {
        status: {
          bsonType: "string",
          description: "must be a string and is required",
        },
        playerId: {
          bsonType: "string",
          description: "must be a string and is required",
        },
        opponentId: {
          bsonType: "string",
          description: "must be a string",
        },
        gameType: {
          bsonType: "int",
          minimum: 1,
          maximum: 3,
          description: "must be an integer in [ 1, 3 ] and is required",
        },
      },
    },
  },
});

3. Security

Getting data from your app that you use to query/insert data in your db is dangerous. You cannot trust the request object. What If I send a number or object? It might break your app.

So always validate your data.

(req, res) => {
  // You should have a validator type on top of your controller
  // Something that will act like this
  if (!req.body?.user?._id || typeof req.body.user._id !== "string") {
    throw new Error("user._id must be defined and a type string");
  }
  if (!req.body?.gameType || typeof req.body.gameType !== "number") {
    throw new Error("gameType must be defined and a type number");
  }
};

4. Requests

I prefer to use async await because I find it clearer in the code rather than having many logic subfunctions.

async (req, res) => {
  let game = await Game.findOneAndUpdate(
    {
      status: "awaiting",
      gameType: req.body.gameType,
      playerId: { $ne: req.body.user._id },
    },
    {
      $set: {
        status: "ready",
        opponentId: req.user._id,
      },
    },
    {
      upsert: true,
    }
  );

  // Create the game if it doesn't exist
  if (!game) {
    game = await Game.insertOne({
      status: "awaiting",
      gameType: req.body.gameType,
      playerId: req.body.user._id,
    });
  }

  // Next logic...
};

The status will help you cover more aspect of your game: when it's awaiting, playing, finished, ... You will only need one index to get stats on those quickly.
I recommend to improve your schema later by adding attributes like: startDate, finishDate, ...

5. Indexes

To ensure that your query will not consume all resources of your cluster, create an index based on the queries you do. In the above example:

db.game.createIndex({status:1, gameType:1, playerId:1},{background:true})

6. Scalability

Now that you're app can do most of the job, how can you ensure that is scale properly?

You will need to ensure that your app is scalable to handle your requests. It depends on your infrastructure resources and type (serverless, your own instance, ...). Use the different networks metrics to adjust the ram & cpu you need.

You will also need to ensure that your Mongo server can be scaled by using a cluster at first, automatically scale your cluster depending on your resources uage.

You can scale horizontally (adding more machines) or vertically (adding more resources)

Many more points can be covered to improve the speed & scalability (cache, sharding,...) but I assume that the above is enough to let you start.

Upvotes: 1

Marco Luzzara
Marco Luzzara

Reputation: 6056

My understanding is that the findOneAndUpdate function offered by mongoose is atomic, and therefore prevents concurrent modification by multiple requests.

Your understanding is correct, findOneAndUpdate is a single-document operation, which means that the changes are applied atomically and the client will never see a partially updated document.

if you have deployed your application on a standalone mongod server, you are 99.99% safe. On the other hand, if you have set up a replica set, your customer could experience an interesting thing. Suppose you are on a 5 members replica set (1 primary and 4 secondaries). At some point there is a network partition

Network partition on replica set

There is a small time frame when 2 members transiently believe to be primaries, in this case Pold and Pnew. It is possible with a weak write concern like w: 1 that the old Primary (Pold) is the only one with the updated document. If during that small time frame, a new findOneAndUpdate for that same document reaches Pnew, then 2 nodes have inconsistent views of the same game match.

Nonetheless, after the connection between partitions is restored, Pold changes are rolled back and the database returns to a consistent state with Pnew as the new primary. To mitigate this rare scenario you could associate the updation to a ClientSession with write concern w: majority, instead of the default one (1).

You did not provide any information on how you designed the architecture of your project, but I would say that you do not actually need this additional complexity.


Even if this does work correctly, please let me know if there is a better way to accomplish my goal

Again, I do not know if needsFill has another meaning, but if it is just there to let you decide if competitorID2 is filled, why don't you delete it and instead check if competitorID2 is null?

Upvotes: 1

Related Questions