Skip to main content

Component Architecture

Spectra Server is built from several interconnected components, each with specific responsibilities. This page provides implementation details for each major component.

WebsocketIncoming (Port 5100)

The ingestion layer that receives data from observer and auxiliary clients. Location: src/connector/websocketIncoming.ts

Responsibilities

  • Accept WebSocket connections from clients
  • Authenticate observer clients (obs_logon event)
  • Authenticate auxiliary clients (aux_logon event)
  • Route authenticated data to MatchController
  • Manage client lifecycle and disconnection

Key Implementation Details

Server Initialization

const serverInstance = process.env.INSECURE == "true" 
  ? createInsecureServer()
  : createServer({ key: readFileSync(process.env.SERVER_KEY!), 
                   cert: readFileSync(process.env.SERVER_CERT!) });

this.wss = new Server(serverInstance, {
  perMessageDeflate: {
    zlibDeflateOptions: { chunkSize: 1024, memLevel: 7, level: 3 },
    zlibInflateOptions: { chunkSize: 10 * 1024 },
    threshold: 1024
  },
  cors: { origin: "*" }
});

serverInstance.listen(5100);
Compression Settings: The perMessageDeflate configuration balances compression ratio with CPU usage. Level 3 compression provides good bandwidth savings without excessive CPU overhead.

Observer Authentication Flow

The obs_logon event handler (src/connector/websocketIncoming.ts:66) performs these validations:
1

Duplicate Check

Prevents the same socket from authenticating twice:
if (WebsocketIncoming.authedClients.find(
  (client) => client.ws.id === ws.id
) != undefined) return;
2

Packet Type Validation

Ensures packet type is DataTypes.AUTH:
if (authenticationData.type !== DataTypes.AUTH) {
  ws.emit('obs_logon_ack', JSON.stringify({
    type: DataTypes.AUTH, 
    value: false, 
    reason: 'Invalid packet.'
  }));
  ws.disconnect();
  return;
}
3

Version Compatibility

Checks client version against server compatibility list:
if (!isCompatibleVersion(authenticationData.clientVersion)) {
  ws.emit('obs_logon_ack', JSON.stringify({
    type: DataTypes.AUTH,
    value: false,
    reason: `Client version ${authenticationData.clientVersion} is not compatible`
  }));
  ws.disconnect();
  return;
}
4

Key Validation

Validates access key via isValidKey() method:
const validity = await this.isValidKey(authenticationData.key);
if (validity.valid === false) {
  ws.emit('obs_logon_ack', JSON.stringify({
    type: DataTypes.AUTH, 
    value: false, 
    reason: validity.reason
  }));
  ws.disconnect();
  return;
}

// Attach organization ID and supporter status
if (validity.organizationId) {
  authenticationData.organizationId = validity.organizationId;
}
authenticationData.isSupporter = validity.isSupporter;
5

Match Creation

Attempts to create or reconnect to match:
const groupSecret = await this.matchController.createMatch(authenticationData);
if (!groupSecret || groupSecret === "") {
  ws.emit('obs_logon_ack', JSON.stringify({
    type: DataTypes.AUTH,
    value: false,
    reason: `Game with Group Code ${authenticationData.groupCode} exists and is still live.`
  }));
  ws.disconnect();
  return;
}
6

Success Response

Sends acknowledgment and registers client:
ws.emit('obs_logon_ack', JSON.stringify({
  type: DataTypes.AUTH, 
  value: true, 
  reason: groupSecret
}));

user.name = authenticationData.obsName;
user.groupCode = authenticationData.groupCode;
WebsocketIncoming.authedClients.push(user);

this.onAuthSuccess(user);

Auxiliary Authentication Flow

Auxiliary clients use a simpler flow via aux_logon (src/connector/websocketIncoming.ts:158):
  • No access key validation (relies on match ID security)
  • Finds existing match by matchId instead of group code
  • Stores playerId for player-specific data routing
  • Sets user.isAuxiliary = true flag
const groupCode = this.matchController.findMatch(authenticationData.matchId);
if (groupCode == null) {
  ws.emit('aux_logon_ack', JSON.stringify({
    type: DataTypes.AUTH,
    value: false,
    reason: `Game with Match ID ${authenticationData.matchId} not found.`
  }));
  ws.disconnect();
  return;
}
Security Note: Auxiliary clients can only connect if a match with their matchId already exists, created by an authenticated observer. This prevents unauthorized aux connections.

Key Validation Logic

The isValidKey() method (src/connector/websocketIncoming.ts:275) implements a fallback chain:
public async isValidKey(key: string): Promise<KeyValidity> {
  // 1. Check if authentication is disabled
  if (process.env.REQUIRE_AUTH_KEY === "false")
    return { valid: true, reason: ValidityReasons.VALID };

  // 2. Check against local AUTH_KEY
  if (process.env.AUTH_KEY === key) 
    return { valid: true, reason: ValidityReasons.VALID };

  // 3. Check backend (if enabled)
  let validity: KeyValidity = { valid: false, reason: ValidityReasons.INVALID };
  if (process.env.USE_BACKEND === "true") {
    validity = await DatabaseConnector.verifyAccessKey(key);
  }

  return validity;
}

Disconnection Handling

The disconnect handler (src/connector/websocketIncoming.ts:229) cleans up auxiliary clients:
ws.on("disconnect", () => {
  const index = WebsocketIncoming.authedClients.findIndex(
    (client) => client.ws.id === ws.id
  );
  if (index != -1) {
    const client = WebsocketIncoming.authedClients[index];
    if (client.playerId !== "") {
      this.matchController.setAuxDisconnected(client.groupCode, client.playerId);
    }
    if (client.isAuxiliary) {
      WebsocketIncoming.authedClients.splice(index, 1);
    }
  }
});
Observer Persistence: Observer clients remain in authedClients even after disconnection to preserve match state. Only auxiliary clients are removed immediately.

Static Methods

disconnectGroupCode(groupCode: string)

Disconnects all clients associated with a group code (src/connector/websocketIncoming.ts:289):
public static disconnectGroupCode(groupCode: string) {
  for (const client of WebsocketIncoming.authedClients) {
    if (client.groupCode === groupCode) {
      client.ws.disconnect();
    }
  }
}
Called by MatchController when removing a match.

WebsocketOutgoing (Port 5200)

The broadcast layer that sends processed game state to frontends. Location: src/connector/websocketOutgoing.ts

Responsibilities

  • Accept WebSocket connections from frontends
  • Manage room-based subscriptions by group code
  • Broadcast match data to subscribed clients
  • Filter sensitive data before transmission

Key Implementation Details

Singleton Pattern

private static instance: WebsocketOutgoing;

public static getInstance(): WebsocketOutgoing {
  if (WebsocketOutgoing.instance == null) 
    WebsocketOutgoing.instance = new WebsocketOutgoing();
  return WebsocketOutgoing.instance;
}
Ensures only one broadcast server exists across the application.

Frontend Logon Handler

Simple room-based subscription (src/connector/websocketOutgoing.ts:59):
ws.once("logon", (msg: string) => {
  const json = JSON.parse(msg);
  ws.join(json.groupCode);  // Join Socket.IO room
  
  ws.emit('logon_success', JSON.stringify({
    groupCode: json.groupCode,
    msg: `Logon succeeded for group code ${json.groupCode}`
  }));
  
  // Send initial match state
  MatchController.getInstance().sendMatchDataForLogon(json.groupCode);
});
Room-Based Broadcasting: Socket.IO rooms enable efficient one-to-many broadcasting. When data is sent to a room, only sockets in that room receive it.

Data Broadcasting Method

The sendMatchData() method (src/connector/websocketOutgoing.ts:87) filters sensitive fields:
sendMatchData(groupCode: string, data: any) {
  const {
    replayLog,
    eventNumber,
    groupSecret,
    playercamUrl,
    timeoutEndTimeout,
    timeoutRemainingLoop,
    ...formattedData
  } = data;

  const deepMod: any = structuredClone(formattedData);
  
  // Remove playercam secrets
  if (deepMod.tools?.playercamsInfo && 
      typeof deepMod.tools.playercamsInfo === "object") {
    delete deepMod.tools.playercamsInfo.secret;
    delete deepMod.tools.playercamsInfo.endTime;
  }

  // Broadcast to room
  this.wss.to(groupCode).emit('match_data', JSON.stringify(deepMod));
}
Filtered Fields:
  • replayLog: Internal event history for replay generation
  • eventNumber: Internal change tracking counter
  • groupSecret: Reconnection authentication token
  • playercamUrl: Private playercam configuration
  • timeoutEndTimeout / timeoutRemainingLoop: Internal Node.js timers
  • tools.playercamsInfo.secret: Playercam API secret
  • tools.playercamsInfo.endTime: Playercam session expiry

MatchController

The orchestration layer that manages match lifecycles and coordinates data flow. Location: src/controller/MatchController.ts

Responsibilities

  • Create and register match instances
  • Route incoming data to correct match
  • Manage outgoing broadcast loop
  • Clean up inactive matches
  • Track team information for group codes

Key Implementation Details

Singleton with State

private static instance: MatchController;
private outgoingWebsocketServer: WebsocketOutgoing = WebsocketOutgoing.getInstance();
private sendInterval: NodeJS.Timeout | null = null;

private matches: Record<string, Match> = {};
private eventNumbers: Record<string, number> = {};
private eventTimes: Record<string, number> = {};

private codeToTeamInfo: Record<string, { leftTeam: AuthTeam; rightTeam: AuthTeam }> = {};
private teamInfoExpiry: Record<string, number> = {};
State Tracking:
  • matches: Active match instances indexed by group code
  • eventNumbers: Last broadcast event number per match
  • eventTimes: Last event timestamp for timeout detection
  • codeToTeamInfo: Team metadata for preview/UI purposes
  • teamInfoExpiry: 12-hour expiry for team info cleanup

Match Creation

The createMatch() method (src/controller/MatchController.ts:42) handles match creation and reconnection:
async createMatch(data: IAuthenticationData) {
  const existingMatch = this.matches[data.groupCode];
  
  // Handle reconnection
  if (existingMatch != null) {
    if (data.groupSecret !== existingMatch.groupSecret) {
      // Wrong secret - reject
      return "";
    }
    // Correct secret - reconnect
    return "reconnected";
  }
  
  // Create new match
  const newMatch = new Match(data);
  this.matches[data.groupCode] = newMatch;
  this.eventNumbers[data.groupCode] = 0;
  
  // Store team info with expiry
  this.codeToTeamInfo[data.groupCode] = { 
    leftTeam: data.leftTeam, 
    rightTeam: data.rightTeam 
  };
  this.teamInfoExpiry[data.groupCode] = Date.now() + 1000 * 60 * 60; // 1 hour
  
  // Start broadcast loop if not running
  this.startOutgoingSendLoop();
  
  return newMatch.groupSecret;
}
Reconnection Security: The groupSecret acts as a password for reconnection. Only clients with the correct secret can reconnect to an existing match.

Broadcast Send Loop

The core broadcast mechanism (src/controller/MatchController.ts:154):
private startOutgoingSendLoop() {
  if (this.sendInterval != null) return;  // Already running
  
  this.sendInterval = setInterval(async () => {
    for (const groupCode in this.matches) {
      // Check if match has new events
      if (this.matches[groupCode].eventNumber > this.eventNumbers[groupCode]) {
        // Broadcast updated state
        this.outgoingWebsocketServer.sendMatchData(
          groupCode, 
          this.matches[groupCode]
        );
        
        // Update tracking
        this.eventNumbers[groupCode] = this.matches[groupCode].eventNumber;
        this.eventTimes[groupCode] = Date.now();
      } else {
        // Check for 30-minute timeout
        if (Date.now() - this.eventTimes[groupCode] > 1000 * 60 * 30) {
          // Complete match in backend if registered
          if (this.matches[groupCode].isRegistered) {
            await DatabaseConnector.completeMatch(this.matches[groupCode]);
          }
          this.removeMatch(groupCode);
        }
      }
    }
  }, 100);  // 100ms interval = 10 Hz max
}
Performance Characteristics:
  • 100ms interval: Limits broadcast rate to 10 Hz
  • Event-driven: Only sends when eventNumber changes
  • Timeout cleanup: Removes matches inactive for 30 minutes
  • Sequential processing: Processes all matches in order
Scalability Consideration: With many concurrent matches, the send loop processes them sequentially. If processing takes >100ms, broadcasts may be delayed. Monitor performance in high-load scenarios.

Match Removal

The removeMatch() method (src/controller/MatchController.ts:78) performs cleanup:
removeMatch(groupCode: string) {
  if (this.matches[groupCode] != null) {
    delete this.matches[groupCode];
    delete this.eventNumbers[groupCode];
    
    // Disconnect all clients
    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;
    }
  }
}

Data Routing

The receiveMatchData() method (src/controller/MatchController.ts:96) routes data:
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;
    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);
      }
    }
  }
}
Multi-Match Auxiliary: Auxiliary data is sent to all matches with the matching matchId. This supports tournament servers running multiple games simultaneously.

REST API Server (Port 5101)

HTTP endpoint server for status checks, previews, and metadata queries. Location: src/index.ts

Endpoints

GET /status

Returns server health and active match count:
app.get("/status", (req: Request, res: Response) => {
  const status = { 
    status: "UP", 
    matchesRunning: MatchController.getInstance().getMatchCount() 
  };
  res.header("Access-Control-Allow-Origin", "*").json(status);
});
Response:
{
  "status": "UP",
  "matchesRunning": 3
}

PUT /createPreview

Creates a preview match for overlay testing (src/index.ts:27):
app.put("/createPreview", async (req: Request, res: Response) => {
  await previewHandler.handlePreviewCreation(req, res);
});
Validates access key and creates a PreviewMatch instance with a 6-character code.

GET /preview/:previewCode

Retrieves preview match data (src/index.ts:31):
app.get("/preview/:previewCode", async (req: Request, res: Response) => {
  const previewCode = req.params.previewCode;
  if (!previewCode || previewCode.length !== 6) {
    return res.status(400).json({ error: "Invalid preview code format" });
  }
  
  const previewMatch = previewHandler.getPreview(previewCode);
  if (!previewMatch) {
    return res.status(404).json({ error: "Preview not found" });
  }
  
  res.status(200).json(previewMatch);
});

GET /getOrgForKey

Validates an access key and returns organization info (src/index.ts:44):
app.get("/getOrgForKey", async (req, res) => {
  const key = req.query.key;
  if (!key || typeof key !== "string") {
    res.status(400).json({ error: "Key is required" });
    return;
  }

  if (process.env.USE_BACKEND === "true") {
    const validity = await DatabaseConnector.verifyAccessKey(key);
    if (validity.valid) {
      res.status(200).json({
        id: validity.organizationId,
        name: validity.organizationName,
        isSupporter: validity.isSupporter,
      });
      return;
    }
  }

  res.status(401).send("401 Unauthorized");
});

GET /getTeamInfoForCode

Returns team metadata for a group code (src/index.ts:66):
app.get("/getTeamInfoForCode", async (req, res) => {
  const groupCode = req.query.groupCode;
  const teamInfo = MatchController.getInstance().getTeamInfoForCode(groupCode);
  
  if (teamInfo) {
    res.status(200).json(teamInfo);
  } else {
    res.status(404).json({ error: "Group code not found" });
  }
});
Response:
{
  "leftTeam": { "name": "Team A", "tricode": "TMA", ... },
  "rightTeam": { "name": "Team B", "tricode": "TMB", ... }
}

Backend-Only Endpoints

When USE_BACKEND=true, additional endpoints are available:
  • GET /getSupportPackages: Lists available support packages
  • GET /client/oauth-callback: Handles Discord OAuth for supporter linking

DatabaseConnector

Backend integration layer for key validation, match registration, and statistics. Location: src/connector/databaseConnector.ts

Responsibilities

  • Validate access keys against backend API
  • Register matches when they start
  • Update match state at round boundaries
  • Complete matches when they end
  • Fetch playercam and name override data
  • Trigger stats collection (supporters only)

Key Methods

verifyAccessKey(key: string)

Validates an access key (src/connector/databaseConnector.ts:22):
public static async verifyAccessKey(key: string): Promise<KeyValidity> {
  const res = await this.apiRequest(`system/validateAccessKey/${key}`, "get");
  
  if (res.status == 200) {
    const data = await res.json();
    const isSupporter = await this.checkIsSupporter(data.id);
    return {
      valid: true,
      reason: ValidityReasons.VALID,
      organizationId: data.id,
      organizationName: data.name,
      isSupporter: isSupporter,
    };
  } else if (res.status == 401) {
    return { valid: false, reason: ValidityReasons.INVALID };
  } else if (res.status == 403) {
    return { valid: false, reason: ValidityReasons.EXPIRED };
  } else {
    return { valid: false, reason: ValidityReasons.UNKNOWN };
  }
}
Returns:
interface KeyValidity {
  valid: boolean;
  reason: ValidityReasons;
  organizationId?: string;
  organizationName?: string;
  isSupporter?: boolean;
}

registerMatch(match: Match)

Registers a match in the backend when it starts (src/connector/databaseConnector.ts:63):
public static async registerMatch(match: any) {
  const { replayLog, eventNumber, timeoutEndTimeout, timeoutRemainingLoop, ...toSend } = match;
  const res = await this.apiRequest(
    `system/match/${match.matchId}/register`, 
    "post", 
    { match: toSend }
  );
}
Strips internal fields before sending to backend.

updateMatch(match: Match)

Updates match state at round boundaries (src/controller/MatchController.ts:234):
if (this.isRegistered && this.roundNumber !== 1) {
  DatabaseConnector.updateMatch(this);
}
Called during shopping phase of each round (except round 1).

completeMatch(match: Match)

Marks match as complete in backend (src/connector/databaseConnector.ts:93):
public static async completeMatch(match: any): Promise<void> {
  const { replayLog, eventNumber, timeoutEndTimeout, timeoutRemainingLoop, ...toSend } = match;
  const res = await this.apiRequest(
    `system/match/${match.matchId}/complete`, 
    "put", 
    { match: toSend }
  );
}
Called when match ends or times out after 30 minutes.

Statistics Methods (Supporters Only)

For organizations with supporter status:
  • statsAddMatch(): Registers match for stats tracking
  • statsUpdateMatchRegion(): Updates match region from player ID
  • statsFetchStats(): Triggers stats fetch from Riot API (15-second delay)
if (this.orgIsSupporter) {
  setTimeout(() => {
    DatabaseConnector.statsFetchStats(this.matchId);
  }, 15000);  // Wait for Riot API availability
}

Playercam Integration

getNameOverridesAndPlayercams() fetches player name overrides and playercam settings:
public static async getNameOverridesAndPlayercams(identifier: string, secret: string) {
  const res = await fetch(
    playercamUrl + "/api/getNameOverridesAndPlayercams/" + identifier + "/" + secret
  );
  
  const data = await res.json();
  return data as IOverridesPlayercamsData;
}
Called during agent select and each shopping phase to get updated configs.

PreviewHandler

Manages preview matches for overlay testing without live games. Location: src/util/previews/PreviewHandler.ts

Responsibilities

  • Create preview matches with dummy data
  • Generate or validate preview codes
  • Manage preview expiry (10-minute TTL)
  • Provide preview data to frontends

Key Implementation

Preview Creation

public async handlePreviewCreation(req: Request, res: Response) {
  const previewData: IPreviewData = req.body;
  
  // Validate access key
  const validity: KeyValidity = await this.wsi.isValidKey(previewData.key);
  if (!validity.valid) {
    return res.status(403).json({ error: "Invalid or expired key" });
  }
  
  // Generate or validate preview code
  let previewCode: string = previewData.previewCode;
  if (!previewCode || previewCode.length !== 6) {
    previewCode = this.generateCode();
  }
  
  // Create preview match
  const previewMatch = new PreviewMatch(previewData);
  this.previews.set(previewCode, previewMatch);
  
  // Set 10-minute expiry
  const timeout = setTimeout(() => {
    this.previews.delete(previewCode);
    this.previewTimeouts.delete(previewCode);
  }, 10 * 60 * 1000);
  this.previewTimeouts.set(previewCode, timeout);
  
  res.status(200).json({ previewCode: previewCode });
}
Preview Expiry: Previews automatically expire after 10 minutes to prevent memory leaks. Frontends can create a new preview if needed.

Code Generation

Valid characters exclude confusing letters:
const validGroupcodeCharacters = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789";
// Excludes: I, O (to avoid confusion with 1, 0)

Component Interaction Diagram

┌─────────────────┐
│ Observer Client │
└────────┬────────┘
         │ obs_logon, obs_data
         │ Port 5100

┌──────────────────────┐
│ WebsocketIncoming    │
│ - Authenticate       │
│ - Version check      │
│ - Key validation     │
└────────┬─────────────┘

         │ createMatch(), receiveMatchData()

┌──────────────────────┐      ┌──────────────────┐
│  MatchController     │◄─────┤ DatabaseConnector│
│ - Manage matches     │      │ - Validate keys  │
│ - Route data         │      │ - Register match │
│ - Broadcast loop     │      │ - Update state   │
└────────┬─────────────┘      └──────────────────┘

         │ receiveMatchSpecificData()

┌──────────────────────┐
│      Match           │
│ - Process events     │
│ - Track state        │
│ - Increment eventNum │
└────────┬─────────────┘

         │ sendMatchData() (when eventNumber changes)

┌──────────────────────┐
│ WebsocketOutgoing    │
│ - Room broadcasting  │
│ - Filter sensitive   │
│ Port 5200            │
└────────┬─────────────┘
         │ match_data events

┌─────────────────┐
│ Broadcast       │
│ Frontends       │
└─────────────────┘

Performance Monitoring

Key Metrics to Track

Active Matches

Monitor MatchController.getMatchCount() to track server load

Broadcast Rate

100ms send loop × number of active matches = broadcasts/second

Event Processing Time

Ensure match processing completes within 100ms window

Memory Usage

Watch for memory leaks in long-running matches

Optimization Tips

High Match Count: If running 50+ concurrent matches, consider:
  • Increasing send loop interval from 100ms to 200ms
  • Implementing worker threads for match processing
  • Using Redis for match state storage

Next Steps

Data Flow

Understand how data flows through these components

Architecture Overview

Return to high-level architecture overview

Build docs developers (and LLMs) love