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

Collapse
X
 
  • Time
  • Show
Clear All
new posts
  • MyrinNew
    Senior Member
    • Feb 2024
    • 5175

    #1

    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 โ”‚
    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜










    ๐Ÿ“Š 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




    More...
Working...