Skip to main content

Matchmaker Service

The CloudGaming matchmaker is a Node.js REST API that tracks available hosts and connects clients to game sessions automatically.

Architecture Overview

The matchmaker provides:
  • Host Heartbeats: Redis-backed host registration with TTL expiration
  • Match Finding: Weighted selection algorithm with region preferences
  • Dynamic ICE: Per-match TURN credential provisioning via Metered API
  • Health Checks: Railway-compatible liveness/readiness probes
  • Transaction Safety: Redis WATCH/MULTI for race-free slot allocation

REST API

Host Heartbeat Endpoint

Hosts send periodic heartbeats to register availability:

POST /api/host/heartbeat

Authentication: Bearer token (host secret)Request Body:
{
  "hostId": "host-uuid-123",
  "roomId": "GAME-ABC-123",
  "region": "us-east",
  "status": "idle",
  "capacity": 4,
  "availableSlots": 3
}
Response:
{
  "success": true,
  "ttl": 30
}

Implementation

// From Server/mm_server/Matchmaker.js:208-255
app.post('/api/host/heartbeat', async(req, res) => {
    const result = HeartbeatSchema.safeParse(req.body);
    if (!result.success) {
        return res.status(400).json({
            success: false,
            error: 'Validation failed',
            issues: formatZodIssues(result.error)
        });
    }
    
    const { hostId, roomId, region, status } = result.data;
    const capacity = Math.max(1, result.data.capacity || 1);
    let availableSlots = result.data.availableSlots ?? capacity;
    availableSlots = Math.max(0, Math.min(capacity, availableSlots));
    
    const key = `host:${hostId}`;
    const value = JSON.stringify({
        hostId,
        roomId,
        region: region || 'local',
        status: availableSlots > 0 ? (status || 'idle') : 'busy',
        capacity,
        availableSlots,
        lastHeartbeat: Date.now()
    });
    
    // Store in Redis with 30s TTL
    const isIdle = availableSlots > 0;
    const multi = redisClient.multi();
    multi.set(key, value, { EX: 30 });
    
    if (isIdle) {
        multi.sAdd('idle_hosts', hostId);
    } else {
        multi.sRem('idle_hosts', hostId);
    }
    await multi.exec();
    
    res.json({ success: true, ttl: 30 });
});

Host State Tracking

Redis Keys:
  • host:{hostId}: Host metadata (JSON, 30s TTL)
  • idle_hosts: Set of host IDs with available slots
Status Values:
  • idle: Available for new connections
  • busy: All slots occupied
  • allocated: Reserved by matchmaker

Automatic Cleanup

// From Server/mm_server/Matchmaker.js:189-206
async function pruneStaleIdleHosts() {
    const stale = [];
    const ids = await redisClient.sMembers('idle_hosts');
    for (const id of ids) {
        const ttl = await redisClient.ttl(`host:${id}`);
        if (ttl === -2) {  // Key doesn't exist
            stale.push(id);
        }
    }
    if (stale.length > 0) {
        await redisClient.sRem('idle_hosts', stale);
        log('info', 'Pruned stale idle hosts', { staleCount: stale.length });
    }
}

// Run every 10 seconds
setInterval(() => pruneStaleIdleHosts(), 10000);
Removes hosts whose heartbeat TTL expired.

Configuration

Environment Variables

# Server
PORT=3000                                 # HTTP server port

# Redis
REDIS_URL=redis://localhost:6379         # Redis connection URL

# Authentication
HOST_SECRET=your-host-secret             # Bearer token for hosts
HOST_SECRET_PREVIOUS=old-secret          # For secret rotation

# Signaling
SIGNALING_PUBLIC_URL=wss://signaling.example.com  # Returned to clients

# Metered TURN
METERED_DOMAIN=your-domain               # Metered account domain
METERED_API_KEY=your-api-key             # Metered API key
METERED_EXPIRY_SECONDS=3600              # TURN credential lifetime

Schema Validation

// From Server/mm_server/Matchmaker.js:164-176
const HeartbeatSchema = z.object({
    hostId: z.string().uuid().or(z.string().min(1)),
    roomId: z.string().min(1),
    region: z.string().optional(),
    status: z.enum(['idle', 'busy', 'allocated']).optional(),
    capacity: z.number().int().positive().optional(),
    availableSlots: z.number().int().nonnegative().optional()
});

const MatchFindSchema = z.object({
    region: z.string().optional(),
    hostId: z.string().optional()
});
Ensures type safety and validation at runtime.

Client Integration

JavaScript Example

// From Client/html-server/index.html:602-665
document.getElementById('findMatchButton').addEventListener('click', async () => {
    const matchStatus = document.getElementById('matchStatus');
    matchStatus.textContent = 'Looking for available hosts...';
    
    try {
        const response = await fetch(`${matchmakerUrl}/api/match/find`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({}),
            mode: 'cors'
        });
        
        const data = await response.json();
        
        if (data.found) {
            matchStatus.textContent = `Found host! Connecting to room ${data.roomId}...`;
            
            // Use returned ICE servers
            currentIceServers = data.iceServers;
            
            // Connect to signaling server
            connectToSignalingServer(data.roomId, data.signalingUrl);
        } else {
            matchStatus.textContent = data.message || 'No hosts available. Try again later.';
        }
    } catch (error) {
        console.error('Matchmaker error:', error);
        matchStatus.textContent = `Connection failed: ${error.message}`;
    }
});

Key Source Files

Matchmaker.js

Main matchmaker implementation with host tracking and match finding logic.

config.js

Configuration loader for environment variables and defaults.
Best Practices:
  • Send heartbeats every 10-15 seconds (TTL is 30s)
  • Use region-based matching for lower latency
  • Monitor /api/hosts/ttl to detect failing hosts
  • Rotate HOST_SECRET periodically using HOST_SECRET_PREVIOUS