Skip to main content
The MatchController handles the complete lifecycle of matches in Spectra Server, from creation through data synchronization to automatic cleanup.

Match Lifecycle

Matches in Spectra Server go through several stages:

1. Match Creation

Matches are created during observer authentication:
async createMatch(data: IAuthenticationData) {
  const existingMatch = this.matches[data.groupCode];
  if (existingMatch != null) {
    if (data.groupSecret !== existingMatch.groupSecret) {
      // Reject: different secret
      return "";
    }
    // Allow reconnection
    return "reconnected";
  }
  
  const newMatch = new Match(data);
  this.matches[data.groupCode] = newMatch;
  this.eventNumbers[data.groupCode] = 0;
  
  this.codeToTeamInfo[data.groupCode] = { 
    leftTeam: data.leftTeam, 
    rightTeam: data.rightTeam 
  };
  this.teamInfoExpiry[data.groupCode] = Date.now() + 1000 * 60 * 60; // 1 hour
  
  this.startOutgoingSendLoop();
  return newMatch.groupSecret;
}
Source: src/controller/MatchController.ts:42-72
The MatchController is a singleton, ensuring only one instance manages all matches across the server.

2. Data Reception

The controller receives match data from authenticated clients:
async receiveMatchData(data: IAuthedData | IAuthedAuxData) {
  data.timestamp = Date.now();

  // Observer data (has groupCode)
  if ("groupCode" in data) {
    const trackedMatch = this.matches[data.groupCode];
    if (trackedMatch == null) {
      return; // Invalid group code
    }
    await trackedMatch.receiveMatchSpecificData(data);
  } 
  // Auxiliary data (has matchId)
  else if ("matchId" in data) {
    for (const match of Object.values(this.matches)) {
      if (match.matchId == data.matchId) {
        await match.receiveMatchSpecificData(data);
      }
    }
  }
}
Source: src/controller/MatchController.ts:96-117

3. State Synchronization

The server broadcasts match updates to overlay clients at 10Hz (100ms intervals):
private startOutgoingSendLoop() {
  if (this.sendInterval != null) return; // Already running
  
  this.sendInterval = setInterval(async () => {
    for (const groupCode in this.matches) {
      // Only send if there are new events
      if (this.matches[groupCode].eventNumber > this.eventNumbers[groupCode]) {
        this.outgoingWebsocketServer.sendMatchData(
          groupCode, 
          this.matches[groupCode]
        );
        this.eventNumbers[groupCode] = this.matches[groupCode].eventNumber;
        this.eventTimes[groupCode] = Date.now();
      }
    }
  }, 100); // 100ms = 10Hz
}
Source: src/controller/MatchController.ts:154-185

4. Automatic Cleanup

Matches are automatically cleaned up after 30 minutes of inactivity:
// Check if the last event was more than 30 minutes ago
if (Date.now() - this.eventTimes[groupCode] > 1000 * 60 * 30) {
  Log.info(
    `Match with group code ${groupCode} has been inactive for more than 30 minutes, removing.`
  );

  try {
    if (this.matches[groupCode].isRegistered) {
      await DatabaseConnector.completeMatch(this.matches[groupCode]);
    }
  } catch (e) {
    Log.error(`Failed to complete match in backend with group code ${groupCode}, ${e}`);
  }

  this.removeMatch(groupCode);
}
Source: src/controller/MatchController.ts:167-182
The 30-minute timeout starts from the last received event, not from match creation. Keep sending heartbeat events to prevent premature cleanup.

5. Manual Match Removal

Matches can also be removed manually:
removeMatch(groupCode: string) {
  if (this.matches[groupCode] != null) {
    delete this.matches[groupCode];
    delete this.eventNumbers[groupCode];
    WebsocketIncoming.disconnectGroupCode(groupCode);
    
    // Stop send loop if no matches remain
    if (Object.keys(this.matches).length == 0 && this.sendInterval != null) {
      clearInterval(this.sendInterval);
      this.sendInterval = null;
    }
  }
}
Source: src/controller/MatchController.ts:78-90

Group Codes and Match Identification

Group Codes

Group codes are the primary identifier for matches:
  • Format: 6-character alphanumeric string (recommended)
  • Uniqueness: Must be unique across active matches
  • Case: Usually uppercase for readability
  • Persistence: Group code remains for 1 hour after match completion (for team info)

Match IDs

Each match also has a unique match ID (UUID) used for:
  • Auxiliary client authentication
  • Backend API integration
  • Cross-referencing match data
Find a match by its ID:
findMatch(matchId: string) {
  return Object.values(this.matches)
    .find((match) => match.matchId == matchId)
    ?.groupCode ?? null;
}
Source: src/controller/MatchController.ts:74-76

Team Configuration and Metadata

Team information is stored separately from match objects with its own expiry:
private codeToTeamInfo: Record<string, { leftTeam: AuthTeam; rightTeam: AuthTeam }> = {};
private teamInfoExpiry: Record<string, number> = {};

Team Data Structure

interface AuthTeam {
  name: string;        // Full team name
  tricode: string;     // 3-4 letter abbreviation
  url: string;         // Team logo URL
  attackStart: boolean; // Which side starts attacking
}

Team Info Cleanup

Team information expires 1 hour after match creation:
const cleanupInterval = setInterval(() => {
  const now = Date.now();
  for (const groupCode in this.teamInfoExpiry) {
    if (now > this.teamInfoExpiry[groupCode]) {
      delete this.codeToTeamInfo[groupCode];
      delete this.teamInfoExpiry[groupCode];
    }
  }
}, 1000 * 60 * 5); // Check every 5 minutes
Source: src/controller/MatchController.ts:22-34

Retrieving Team Information

public getTeamInfoForCode(groupCode: string) {
  const teamInfo = this.codeToTeamInfo[groupCode];
  if (teamInfo) {
    return teamInfo;
  } else {
    return undefined;
  }
}
Source: src/controller/MatchController.ts:188-195

Event Number Tracking

Each match has an event number that increments with every state change:
private matches: Record<string, Match> = {};
private eventNumbers: Record<string, number> = {};
private eventTimes: Record<string, number> = {};
The event number is used to:
  1. Track when new data is available
  2. Determine if updates should be broadcast
  3. Monitor match activity for cleanup
if (this.matches[groupCode].eventNumber > this.eventNumbers[groupCode]) {
  // New event available, broadcast it
  this.outgoingWebsocketServer.sendMatchData(groupCode, this.matches[groupCode]);
  this.eventNumbers[groupCode] = this.matches[groupCode].eventNumber;
  this.eventTimes[groupCode] = Date.now();
}
Source: src/controller/MatchController.ts:162-165

Auxiliary Client Management

The controller manages auxiliary client connections (player cameras, etc.):
setAuxDisconnected(groupCode: string, playerId: string) {
  if (this.matches[groupCode] != null) {
    this.matches[groupCode].setAuxDisconnected(playerId);
  }
}
Source: src/controller/MatchController.ts:128-132 When an auxiliary client disconnects, the match is notified to update player camera states.

Match Data for Overlay Logon

When a new overlay client connects, it receives the current match state:
sendMatchDataForLogon(groupCode: string) {
  if (this.matches[groupCode] != null) {
    const {
      replayLog,
      eventNumber,
      timeoutEndTimeout,
      timeoutRemainingLoop,
      playercamUrl,
      ...formattedData
    } = this.matches[groupCode] as any;

    this.outgoingWebsocketServer.sendMatchData(groupCode, formattedData);
  }
}
Source: src/controller/MatchController.ts:134-152
Internal fields (replayLog, timeouts, etc.) are excluded from the data sent to overlay clients.

Monitoring Matches

Get Active Match Count

getMatchCount() {
  return Object.keys(this.matches).length;
}
Source: src/controller/MatchController.ts:92-94

Best Practices

1

Use meaningful group codes

Choose group codes that are easy to communicate and unlikely to collide:
// Good
groupCode: "FNATIC"
groupCode: "MATCH1"

// Avoid
groupCode: "A"
groupCode: "123"
2

Store group secrets securely

Save the group secret returned during authentication to enable reconnection:
const secret = acknowledgment.reason; // On successful auth
// Store secret for reconnection
3

Send regular heartbeats

To prevent 30-minute timeout, send periodic events even during pauses:
// Send heartbeat every 5 minutes during long pauses
setInterval(() => {
  sendMatchData({ type: "heartbeat", timestamp: Date.now() });
}, 5 * 60 * 1000);
4

Handle reconnection gracefully

If connection drops, authenticate again with the same group code and secret:
if (response.reason === "reconnected") {
  // Successfully reconnected to existing match
}

Build docs developers (and LLMs) love