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?
- Flexible Schema: Match results can vary by game mode
- Aggregation Pipeline: Perfect for complex matchmaking queries
- Denormalization: Trade storage for query speed
- Horizontal Scaling: Better for high-concurrency gaming systems
Why NestJS?
-
Built-in Scheduling:
@Cron
decorator for background jobs - Dependency Injection: Clean, testable architecture
- TypeScript: Type safety for complex algorithms
- 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
- Matchmaking is NOT CRUD - It's algorithm design, state management, and real-time decision making
- Transactions are critical in systems where consistency matters
- Denormalization has a place when query performance is paramount
- Background jobs enable autonomous system behavior
- 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
- NestJS Documentation
- MongoDB Aggregation Pipeline
- ELO Rating System Explained
- Transaction Best Practices
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

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/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.