Building a Real-Time Matchmaking System with NestJS and MongoDB ๐ŸŽฎ

How I Built a Competitive Gaming Matchmaking API Like Valorant and CS:GO

Have you ever wondered how games like Valorant, CS:GO, or League of Legends instantly find you opponents at your skill level? In this article, I’ll walk you through bui…


This content originally appeared on DEV Community and was authored by Jeremiah Anku Coblah

How I Built a Competitive Gaming Matchmaking API Like Valorant and CS:GO

Have you ever wondered how games like Valorant, CS:GO, or League of Legends instantly find you opponents at your skill level? In this article, I'll walk you through building a production-ready matchmaking system from scratch using NestJS, MongoDB, and intelligent algorithms.

๐ŸŽฏ What We're Building

A backend matchmaking system that:

  • โœ… Automatically pairs players based on skill level (ELO rating)
  • โœ… Groups players by geographic region to minimize lag
  • โœ… Implements a fair queueing system (FIFO - First In, First Out)
  • โœ… Uses smart algorithms to balance wait times vs match quality
  • โœ… Updates player ratings after matches using the ELO system
  • โœ… Handles edge cases like odd numbers of players and long wait times

This isn't just CRUD - it's a real-world system design challenge that requires algorithm thinking, database optimization, and state management.

๐Ÿ—๏ธ System Architecture

Core Components

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”      โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”      โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚   Players   โ”‚ โ”€โ”€โ”€โ–ถ โ”‚  Queue API   โ”‚ โ”€โ”€โ”€โ–ถ โ”‚   Queue DB  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜      โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜      โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                            โ”‚
                            โ–ผ
                   โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                   โ”‚  Matchmaking    โ”‚ โ—€โ”€โ”€โ”€โ”€ Runs every 10s
                   โ”‚    Service      โ”‚
                   โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                            โ”‚
                            โ–ผ
                   โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                   โ”‚   Match API     โ”‚
                   โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                            โ”‚
                            โ–ผ
                   โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                   โ”‚   Match DB      โ”‚
                   โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

๐Ÿ“Š Database Schema Design

User Schema

Stores player profiles and their current state:

{
  _id: ObjectId,
  username: string,
  rating: number,        // ELO rating (starts at 1200)
  region: string,        // Encrypted: "NA", "EU", "ASIA"
  status: string,        // Encrypted: "idle", "searching", "in_match"
  matchHistory: ObjectId[],
  createdAt: Date
}

Why encrypt region and status? Security best practice for sensitive player data.

Queue Schema

The "waiting room" for players looking for matches:

{
  _id: ObjectId,
  userId: ObjectId,      // Reference to User
  username: string,      // Denormalized for faster queries
  rating: number,        // Denormalized to avoid joins
  region: string,
  mode: string,          // "1v1", "2v2", "5v5"
  status: "searching",
  joinedAt: Date
}

Denormalization Trade-off: We duplicate username and rating here for query performance. In matchmaking, speed matters more than storage.

Match Schema

Records of created matches:

{
  _id: ObjectId,
  status: string,        // "pending", "active", "finished"
  mode: string,
  players: [{
    userId: ObjectId,
    username: string,
    rating: number,
    team?: string        // For team-based modes
  }],
  winner: ObjectId,
  result: {
    winnerRating: number,
    loserRating: number,
    ratingChange: number
  },
  createdAt: Date
}

๐Ÿง  The Matchmaking Algorithm

This is the heart of the system. Here's how it works:

Step 1: Automatic Scheduling

@Cron(CronExpression.EVERY_10_SECONDS)
async runMatchmaking() {
  this.logger.log('๐Ÿ” Starting matchmaking scan...');
  await this.find1v1Matches();
}

Using NestJS's @Cron decorator, the matchmaking service runs automatically every 10 seconds in the background.

Step 2: Fetch and Group Players

const queuedPlayers = await this.queueModel.aggregate([
  { $match: { mode: '1v1' } },
  { $sort: { joinedAt: 1 } },        // FIFO ordering
  {
    $group: {
      _id: '$region',                 // Group by region
      players: { $push: '$$ROOT' }
    }
  }
]);

Why MongoDB Aggregation? It's incredibly efficient for complex queries. We get region-grouped, time-sorted data in a single database call.

Step 3: Compatibility Check

arePlayersCompatible(player1: any, player2: any): boolean {
  // Rule 1: Same region
  if (player1.region !== player2.region) return false;

  // Rule 2: Rating tolerance
  const ratingDiff = Math.abs(player1.rating - player2.rating);
  const RATING_TOLERANCE = 100;

  if (ratingDiff > RATING_TOLERANCE) return false;

  // Rule 3: Wait time flexibility
  const avgWaitTime = (player1WaitTime + player2WaitTime) / 2;
  if (avgWaitTime > 30000) {
    // After 30s, allow ยฑ200 rating difference
    return ratingDiff <= 200;
  }

  return true;
}

Smart Trade-offs:

  • First 30 seconds: Strict ยฑ100 rating for fair matches
  • After 30 seconds: Lenient ยฑ200 rating to prevent long waits

Step 4: Match Creation with Transactions

async createMatch(players: any[], mode: string) {
  const session = await this.queueModel.db.startSession();
  session.startTransaction();

  try {
    // 1. Create match document
    const match = new this.matchModel({ /* ... */ });
    await match.save({ session });

    // 2. Remove players from queue
    await this.queueModel.deleteMany({ 
      userId: { $in: userIds } 
    }, { session });

    // 3. Update user statuses to "in_match"
    await this.userModel.updateMany({ 
      _id: { $in: userIds } 
    }, { 
      status: encrypt('in_match') 
    }, { session });

    // 4. Add to match history
    await this.userModel.updateMany({ 
      _id: { $in: userIds } 
    }, { 
      $push: { matchHistory: match._id } 
    }, { session });

    await session.commitTransaction();
    return match;
  } catch (error) {
    await session.abortTransaction();
    throw error;
  } finally {
    session.endSession();
  }
}

Why Transactions? Ensures atomicity. Either all operations succeed, or none do. This prevents bugs like:

  • Player in two matches simultaneously
  • Match created but players still in queue
  • Inconsistent state between collections

๐ŸŽฒ ELO Rating System

After a match finishes, we update ratings using the classic ELO algorithm:

calculateELO(winnerRating: number, loserRating: number) {
  const K = 32; // K-factor (rating change magnitude)

  // Expected win probability
  const expectedWinner = 1 / (1 + Math.pow(10, (loserRating - winnerRating) / 400));
  const expectedLoser = 1 / (1 + Math.pow(10, (winnerRating - loserRating) / 400));

  // Calculate changes
  const winnerChange = Math.round(K * (1 - expectedWinner));
  const loserChange = Math.round(K * (0 - expectedLoser));

  return {
    newWinnerRating: winnerRating + winnerChange,
    newLoserRating: loserRating + loserChange,
    ratingChange: winnerChange,
  };
}

Example:

  • Player A (1200) beats Player B (1250)
  • Player A: 1200 โ†’ 1225 (+25 points)
  • Player B: 1250 โ†’ 1235 (-15 points)

The upset victory gives A more points since B was favored.

๐Ÿ” Security & Performance Features

1. Data Encryption

Sensitive fields like region and status are encrypted at rest:

import { encrypt, decrypt } from 'src/middlewares/encryption/encrypt';

// When saving
const encryptedRegion = encrypt(region);
const encryptedStatus = encrypt(status);

// When reading
const decryptedRegion = decrypt(user.region);

2. Region Validation

We validate region codes to prevent bad data:

if (!isValidCountryCode(region)) {
  throw new BadRequestException('Invalid region code');
}

3. Caching Layer

Frequently accessed user data is cached to reduce database load:

await this.cacheManager.set(
  `user:${username}`,
  { id, username, region },
  3600  // 1 hour TTL
);

4. Input Validation

Every API endpoint validates inputs:

// Can't join queue if already in a match
if (decryptedStatus === "in_match") {
  throw new BadRequestException("User is currently in a match");
}

// Can't join queue twice
const alreadyInQueue = await this.queueModel.findOne({ userId });
if (alreadyInQueue) {
  throw new BadRequestException("User already in queue");
}

๐Ÿ›ฃ๏ธ API Endpoints

User Management

POST   /users/register          # Create new player
GET    /users/:id               # Get player profile

Queue Management

POST   /queue/join              # Join matchmaking queue
POST   /queue/leave             # Leave queue
GET    /queue/status/:userId    # Check queue position

Matchmaking

POST   /matchmaking/trigger     # Manual trigger (testing)
GET    /matchmaking/queue-stats # Queue statistics

Match Management

GET    /matches                 # List all matches (paginated)
GET    /matches/:id             # Get match details
GET    /matches/active/all      # Get active matches
GET    /matches/history/:userId # User's match history
POST   /matches/:id/start       # Start a match
POST   /matches/:id/finish      # Finish match & update ratings
POST   /matches/:id/cancel      # Cancel match (admin)

๐Ÿงช Testing the System

Test Scenario: Create a Match

# 1. Create two players
curl -X POST http://localhost:3000/users/register \
  -H "Content-Type: application/json" \
  -d '{"username":"Alice","region":"NA","status":"idle"}'

curl -X POST http://localhost:3000/users/register \
  -H "Content-Type: application/json" \
  -d '{"username":"Bob","region":"NA","status":"idle"}'

# 2. Both join queue
curl -X POST http://localhost:3000/queue/join \
  -H "Content-Type: application/json" \
  -d '{"userId":"ALICE_ID","mode":"1v1"}'

curl -X POST http://localhost:3000/queue/join \
  -H "Content-Type: application/json" \
  -d '{"userId":"BOB_ID","mode":"1v1"}'

# 3. Wait 10 seconds (automatic matching)
# Check server logs:
# ๐Ÿ” Starting matchmaking scan...
# โœ… MATCH FOUND: Alice (1200) vs Bob (1200)
# ๐ŸŽฎ Match created: 65a1b2c3d4e5f6789

# 4. Verify match was created
curl http://localhost:3000/matches

# 5. Start the match
curl -X POST http://localhost:3000/matches/MATCH_ID/start

# 6. Finish match (Alice wins)
curl -X POST http://localhost:3000/matches/MATCH_ID/finish \
  -H "Content-Type: application/json" \
  -d '{"winnerId":"ALICE_ID","loserId":"BOB_ID"}'

# 7. Check updated ratings
curl http://localhost:3000/users/ALICE_ID  # Rating: 1225
curl http://localhost:3000/users/BOB_ID    # Rating: 1185

๐Ÿš€ Key Technical Decisions

Why MongoDB over PostgreSQL?

  1. Flexible Schema: Match results can vary by game mode
  2. Aggregation Pipeline: Perfect for complex matchmaking queries
  3. Denormalization: Trade storage for query speed
  4. Horizontal Scaling: Better for high-concurrency gaming systems

Why NestJS?

  1. Built-in Scheduling: @Cron decorator for background jobs
  2. Dependency Injection: Clean, testable architecture
  3. TypeScript: Type safety for complex algorithms
  4. Mongoose Integration: Seamless MongoDB ODM

Why Transactions?

Gaming systems require strong consistency. A player can't be in two states simultaneously. Transactions ensure atomic operations across multiple collections.

๐Ÿ“ˆ Performance Optimizations

1. Denormalization

// Queue stores username and rating directly
// Avoids JOIN operations during matchmaking
{
  userId: ObjectId,
  username: "Player1",  // โ† Denormalized
  rating: 1200          // โ† Denormalized
}

2. Indexing Strategy

// Queue collection indexes
queueSchema.index({ region: 1, mode: 1, joinedAt: 1 });
queueSchema.index({ userId: 1 });

// User collection indexes
userSchema.index({ username: 1 }, { unique: true });
userSchema.index({ region: 1, rating: 1 });

3. Aggregation Pipeline

Single query to get grouped, sorted players:

// Instead of:
// 1. Fetch all players
// 2. Group by region in code
// 3. Sort by join time in code

// We do:
$match โ†’ $sort โ†’ $group  // All in database!

๐ŸŽ“ What I Learned

Algorithm Design

  • Balancing fairness (skill matching) vs speed (wait times)
  • Implementing gradual tolerance increases
  • FIFO queue management

System Design

  • State machines (idle โ†’ searching โ†’ in_match โ†’ idle)
  • Background job scheduling
  • Atomic operations with transactions

Database Optimization

  • When to denormalize for performance
  • Effective use of aggregation pipelines
  • Strategic indexing for query patterns

Real-World Trade-offs

  • Perfect matches vs reasonable wait times
  • Data consistency vs system complexity
  • Storage cost vs query performance

๐Ÿ”ฎ Future Enhancements

1. WebSocket Integration

Real-time notifications when matches are found:

@WebSocketGateway()
export class MatchmakingGateway {
  @WebSocketServer()
  server: Server;

  notifyMatchFound(match: Match) {
    match.players.forEach(player => {
      this.server.to(player.userId).emit('match-found', match);
    });
  }
}

2. Team-Based Matchmaking

Support for 2v2 and 5v5 modes with team balancing:

async find5v5Matches() {
  // Find 10 players
  // Balance teams so sum(team1.ratings) โ‰ˆ sum(team2.ratings)
}

3. Regional Fallback

If no match in primary region after 60s, try nearby regions:

const REGION_FALLBACKS = {
  'NA': ['SA'],
  'EU': ['ME'],
  'ASIA': ['OCE']
};

4. Priority Queue

VIP players or those who've waited longest get priority matching.

5. Anti-Cheat Integration

Track suspicious rating changes, match dodging, etc.

๐Ÿ’ก Key Takeaways

  1. Matchmaking is NOT CRUD - It's algorithm design, state management, and real-time decision making
  2. Transactions are critical in systems where consistency matters
  3. Denormalization has a place when query performance is paramount
  4. Background jobs enable autonomous system behavior
  5. Trade-offs are everywhere - perfect vs fast, consistency vs availability

๐Ÿ› ๏ธ Tech Stack

  • Framework: NestJS (Node.js)
  • Database: MongoDB with Mongoose ODM
  • Scheduling: @nestjs/schedule
  • Caching: @nestjs/cache-manager
  • Encryption: Custom AES encryption middleware
  • Validation: class-validator, class-transformer

๐Ÿ“ฆ Repository & Installation

# Clone repository
git clone https://github.com/Jerry-Khobby/matchmaking-system

# Install dependencies
npm install

# Set up MongoDB
# Update connection string in app.module.ts

# Run in development
npm run start:dev

๐ŸŽฏ Conclusion

Building a matchmaking system taught me that backend development goes far beyond CRUD operations. It requires:

  • Algorithmic thinking for pairing logic
  • System design for state management
  • Database optimization for performance
  • Error handling for edge cases

This project demonstrates production-ready backend skills: handling concurrent users, making intelligent automated decisions, and maintaining data consistency in complex scenarios.

If you're looking to level up from tutorial projects to real system design challenges, building a matchmaking system is an excellent way to do it.

๐Ÿ“š Resources

Questions or suggestions? Drop a comment below! I'd love to discuss system design trade-offs and algorithm optimizations.

Found this helpful? Share it with other developers building real-world backend systems! ๐Ÿš€

Tags: #NestJS #MongoDB #SystemDesign #Backend #Gaming #Algorithms #NodeJS #TypeScript


This content originally appeared on DEV Community and was authored by Jeremiah Anku Coblah


Print Share Comment Cite Upload Translate Updates
APA

Jeremiah Anku Coblah | Sciencx (2025-10-04T12:20:02+00:00) Building a Real-Time Matchmaking System with NestJS and MongoDB ๐ŸŽฎ. Retrieved from https://www.scien.cx/2025/10/04/building-a-real-time-matchmaking-system-with-nestjs-and-mongodb-%f0%9f%8e%ae/

MLA
" » Building a Real-Time Matchmaking System with NestJS and MongoDB ๐ŸŽฎ." Jeremiah Anku Coblah | Sciencx - Saturday October 4, 2025, https://www.scien.cx/2025/10/04/building-a-real-time-matchmaking-system-with-nestjs-and-mongodb-%f0%9f%8e%ae/
HARVARD
Jeremiah Anku Coblah | Sciencx Saturday October 4, 2025 » Building a Real-Time Matchmaking System with NestJS and MongoDB ๐ŸŽฎ., viewed ,<https://www.scien.cx/2025/10/04/building-a-real-time-matchmaking-system-with-nestjs-and-mongodb-%f0%9f%8e%ae/>
VANCOUVER
Jeremiah Anku Coblah | Sciencx - » Building a Real-Time Matchmaking System with NestJS and MongoDB ๐ŸŽฎ. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2025/10/04/building-a-real-time-matchmaking-system-with-nestjs-and-mongodb-%f0%9f%8e%ae/
CHICAGO
" » Building a Real-Time Matchmaking System with NestJS and MongoDB ๐ŸŽฎ." Jeremiah Anku Coblah | Sciencx - Accessed . https://www.scien.cx/2025/10/04/building-a-real-time-matchmaking-system-with-nestjs-and-mongodb-%f0%9f%8e%ae/
IEEE
" » Building a Real-Time Matchmaking System with NestJS and MongoDB ๐ŸŽฎ." Jeremiah Anku Coblah | Sciencx [Online]. Available: https://www.scien.cx/2025/10/04/building-a-real-time-matchmaking-system-with-nestjs-and-mongodb-%f0%9f%8e%ae/. [Accessed: ]
rf:citation
» Building a Real-Time Matchmaking System with NestJS and MongoDB ๐ŸŽฎ | Jeremiah Anku Coblah | Sciencx | https://www.scien.cx/2025/10/04/building-a-real-time-matchmaking-system-with-nestjs-and-mongodb-%f0%9f%8e%ae/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.