DEV Community

Jeremiah Anku Coblah
Jeremiah Anku Coblah

Posted on

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 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      โ”‚
                   โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
Enter fullscreen mode Exit fullscreen mode

๐Ÿ“Š 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
}
Enter fullscreen mode Exit fullscreen mode

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

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

๐Ÿง  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();
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

2. Region Validation

We validate region codes to prevent bad data:

if (!isValidCountryCode(region)) {
  throw new BadRequestException('Invalid region code');
}
Enter fullscreen mode Exit fullscreen mode

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

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

๐Ÿ›ฃ๏ธ API Endpoints

User Management

POST   /users/register          # Create new player
GET    /users/:id               # Get player profile
Enter fullscreen mode Exit fullscreen mode

Queue Management

POST   /queue/join              # Join matchmaking queue
POST   /queue/leave             # Leave queue
GET    /queue/status/:userId    # Check queue position
Enter fullscreen mode Exit fullscreen mode

Matchmaking

POST   /matchmaking/trigger     # Manual trigger (testing)
GET    /matchmaking/queue-stats # Queue statistics
Enter fullscreen mode Exit fullscreen mode

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

๐Ÿงช 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
Enter fullscreen mode Exit fullscreen mode

๐Ÿš€ 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
}
Enter fullscreen mode Exit fullscreen mode

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

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

๐ŸŽ“ 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);
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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

3. Regional Fallback

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

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

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

๐ŸŽฏ 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

Top comments (0)