Ludo Tournament API — Bracket Generation, Match Resolution & Prize Distribution

Running a Ludo tournament at scale — whether 16 players in a weekly championship or 1,024 players in a championship qualifier — demands a complete backend infrastructure that goes far beyond simple matchmaking. This guide covers the full tournament stack: bracket generation algorithms (single elimination and Swiss-system), the tournament lifecycle state machine, match resolution with Elo rating updates, automated prize pool distribution, and webhook-driven payment triggers. Every component is designed to be authoritative, auditable, and resilient to mid-tournament failures.

Tournament Lifecycle State Machine

Every tournament progresses through a strict sequence of states, each with defined entry conditions and allowed transitions. The server enforces this state machine — no action is valid unless the tournament is in the appropriate state. This prevents players from registering after check-in closes, matches from being reported before the bracket is generated, or prizes from distributing before the finals conclude.

TypeScript
// TournamentState — exhaustive state enum with transition guards
enum TournamentState {
  CREATED         = 'created',         // Tournament config saved, registration not yet open
  REGISTRATION    = 'registration',    // Players can register and pay entry fees
  CHECK_IN        = 'check_in',        // Registered players must confirm attendance
  SEEDING         = 'seeding',          // Bracket generation in progress (immutable once started)
  IN_PROGRESS     = 'in_progress',     // Matches being played, bracket live
  COMPLETED       = 'completed',       // All matches resolved, prizes pending distribution
  CANCELLED       = 'cancelled',       // Cancelled before completion — refunds triggered
  ABORTED         = 'aborted'          // Server failure mid-tournament
}

/**
 * Allowed transitions map.
 * Attempting a transition not listed here throws an InvalidStateTransition error.
 */
const ALLOWED_TRANSITIONS = new Map<TournamentState, TournamentState[]>([
  [TournamentState.CREATED,        [TournamentState.REGISTRATION, TournamentState.CANCELLED]],
  [TournamentState.REGISTRATION,   [TournamentState.CHECK_IN, TournamentState.CANCELLED]],
  [TournamentState.CHECK_IN,        [TournamentState.SEEDING, TournamentState.CANCELLED]],
  [TournamentState.SEEDING,         [TournamentState.IN_PROGRESS]],
  [TournamentState.IN_PROGRESS,    [TournamentState.COMPLETED, TournamentState.ABORTED]],
  [TournamentState.COMPLETED,       []],
  [TournamentState.CANCELLED,       []],
  [TournamentState.ABORTED,         [TournamentState.REGISTRATION]] // Can restart if aborted
]);

class Tournament {
  constructor(config) {
    this.id = uuidv4();
    this.name = config.name;
    this.format = config.format;           // 'single_elimination' | 'double_elimination' | 'swiss'
    this.state = TournamentState.CREATED;
    this.players = new Map();          // playerId -> TournamentPlayer
    this.bracket = null;
    this.currentRound = 0;
    this.matches = new Map();           // matchId -> Match
    this.prizePool = config.prizePool;
    this.entryFee = config.entryFee || 0;
    this.createdAt = Date.now();
    this.startedAt = null;
    this.completedAt = null;
  }

  transitionTo(newState, reason = '') {
    const allowed = ALLOWED_TRANSITIONS.get(this.state) || [];
    if (!allowed.includes(newState)) {
      throw new Error(
        `Cannot transition from {this.state} to {newState}. Allowed: [{allowed.join(', ')}]`
      );
    }
    const prev = this.state;
    this.state = newState;
    console.log(`Tournament {this.id}: {prev} -> {newState} ({reason})`);
    this.emit('tournament:state-changed', { tournament: this, from: prev, to: newState, reason });
  }

  // ─── Registration & Check-in ──────────────────────────────────

  registerPlayer(player, entryPayment) {
    if (this.state !== TournamentState.REGISTRATION) {
      throw new Error('Registration is not open');
    }
    if (this.players.size >= this.maxPlayers) {
      throw new Error('Tournament is full');
    }
    if (this.players.has(player.id)) {
      throw new Error('Already registered');
    }

    const entry = {
      id: uuidv4(),
      playerId: player.id,
      displayName: player.displayName,
      rating: player.rating,
      registeredAt: Date.now(),
      checkedIn: false,
      seed: null,           // Assigned during seeding
      bracketPosition: null,
      eliminated: false,
      currentMatchId: null,
      eloHistory: []
    };
    this.players.set(player.id, entry);
    return entry;
  }

  checkInPlayer(playerId) {
    if (this.state !== TournamentState.CHECK_IN) return;
    const p = this.players.get(playerId);
    if (p) { p.checkedIn = true; }
  }

  getCheckedInPlayers() {
    return [...this.players.values()].filter(p => p.checkedIn);
  }

  // ─── Bracket Generation ───────────────────────────────────────

  generateBracket() {
    if (this.state !== TournamentState.SEEDING) {
      throw new Error('Cannot generate bracket in current state');
    }
    const checkedIn = this.getCheckedInPlayers();

    // Remove no-shows (checked out players who didn't confirm)
    checkedIn.forEach(p => this.players.delete(p.playerId));

    if (checkedIn.length < 2) {
      this.transitionTo(TournamentState.CANCELLED, 'Not enough participants');
      return;
    }

    // Seed players by rating
    const seeded = checkedIn.sort((a, b) => b.rating - a.rating);
    seeded.forEach((p, idx) => { p.seed = idx + 1; });

    if (this.format === 'single_elimination') {
      this.bracket = generateSingleEliminationBracket(seeded);
    } else if (this.format === 'swiss') {
      this.bracket = initializeSwissTournament(seeded);
    }

    this.currentRound = 1;
    this.transitionTo(TournamentState.IN_PROGRESS, 'Bracket generated');
  }

  // ─── Match Resolution ────────────────────────────────────────

  resolveMatch(matchId, result) {
    if (this.state !== TournamentState.IN_PROGRESS) {
      throw new Error('Tournament is not in progress');
    }

    const match = this.matches.get(matchId);
    if (!match) throw new Error('Match not found');
    if (match.status === 'resolved') throw new Error('Match already resolved');

    // Validate result signature from game server
    if (!validateMatchResult(result, match, this.secretKey)) {
      throw new Error('Invalid match result signature');
    }

    // Update Elo ratings
    const { winnerId, loserId } = result;
    const winner = this.players.get(winnerId);
    const loser = this.players.get(loserId);
    const eloDelta = calculateTournamentElo(winner.rating, loser.rating);

    winner.rating += eloDelta;
    loser.rating -= eloDelta;
    winner.eloHistory.push({ delta: eloDelta, opponent: loserId, round: this.currentRound });
    loser.eloHistory.push({ delta: -eloDelta, opponent: winnerId, round: this.currentRound });

    // Mark match resolved and advance bracket
    match.status = 'resolved';
    match.winnerId = winnerId;
    match.loserId = loserId;
    loser.eliminated = true;

    this.advanceBracket(match);

    // Check if tournament is complete
    if (isTournamentComplete(this)) {
      this.transitionTo(TournamentState.COMPLETED, 'All matches resolved');
      this.distributePrizes();
    }

    this.emit('match:resolved', { tournament: this, match, eloDelta });
  }

  // ─── Prize Distribution ──────────────────────────────────────

  distributePrizes() {
    const placements = calculateFinalPlacements(this);
    const prizes = calculatePrizeDistribution(placements, this.prizePool);

    prizes.forEach(prize => {
      this.triggerPrizeWebhook(prize);
    });

    return prizes;
  }
}

Bracket Generation — Single Elimination & Swiss

Bracket generation is the most architecturally complex part of a tournament system. Two formats are most common for Ludo tournaments: single elimination (lose once and you're out, fastest format for large fields) and Swiss-system (everyone plays every round regardless of wins/losses, best for determining true skill ranking when a knockout isn't practical). The single elimination algorithm below handles power-of-two padding, top-seeding to prevent the best players meeting early, and byes for odd player counts.

TypeScript
// Bracket Generation — Single Elimination with seeded draw
// Prevents top seeds (1 vs 2, 3 vs 4) from meeting until the final rounds.

interface SeededPlayer {
  playerId: string;
  displayName: string;
  rating: number;
  seed: number;
}

interface BracketMatch {
  matchId: string;
  round: number;
  position: number;       // Position in round bracket (1-indexed)
  player1Id: string | null;
  player2Id: string | null;
  winnerId: string | null;
  loserId: string | null;
  status: 'pending' | 'open' | 'resolved';
  nextMatchId: string | null;
  bracketSide: 'winners' | 'losers';
}

interface Bracket {
  format: string;
  totalRounds: number;
  rounds: Map<number, BracketMatch[]>;
  players: Map<string, SeededPlayer>;
  totalParticipants: number;
}

function generateSingleEliminationBracket(seededPlayers: SeededPlayer[]): Bracket {
  // Step 1: Pad to next power of 2
  const n = seededPlayers.length;
  const bracketSize = Math.pow(2, Math.ceil(Math.log2(n)));
  const byes = bracketSize - n;

  // Step 2: Create bracket structure with seeded draw positions
  const rounds = new Map<number, BracketMatch[]>();
  const totalRounds = Math.log2(bracketSize);
  const allMatches = new Map<string, BracketMatch>();

  // Create all match objects
  for (let round = 1; round <= totalRounds; round++) {
    const matchesInRound = bracketSize / Math.pow(2, round);
    const roundMatches = [];

    for (let pos = 1; pos <= matchesInRound; pos++) {
      const matchId = `r{round}m{pos}`;
      const match: BracketMatch = {
        matchId,
        round,
        position: pos,
        player1Id: null,
        player2Id: null,
        winnerId: null,
        loserId: null,
        status: 'pending',
        nextMatchId: round < totalRounds ? `r{round + 1}m{Math.ceil(pos / 2)}` : null,
        bracketSide: 'winners'
      };
      roundMatches.push(match);
      allMatches.set(matchId, match);
    }
    rounds.set(round, roundMatches);
  }

  // Step 3: Seeded placement using the standard bracket draw formula
  // Seeds are placed so that 1 always meets the highest remaining seed in finals
  const seedOrder = generateBracketSeedOrder(bracketSize);
  const firstRoundMatches = rounds.get(1);

  seededPlayers.forEach((player, idx) => {
    const bracketSlot = seedOrder[idx];
    const matchIdx = Math.floor((bracketSlot - 1) / 2);
    const isPlayer1 = (bracketSlot - 1) % 2 === 0;
    const match = firstRoundMatches[matchIdx];

    if (isPlayer1) {
      match.player1Id = player.playerId;
    } else {
      match.player2Id = player.playerId;
    }
  });

  // Step 4: Mark bye matches (where one slot is empty)
  firstRoundMatches.forEach(match => {
    if (match.player1Id && !match.player2Id) {
      // Player 1 gets a bye — advance automatically
      match.status = 'resolved';
      match.winnerId = match.player1Id;
      advanceByeWinner(match, allMatches, rounds);
    } else if (!match.player1Id && match.player2Id) {
      match.status = 'resolved';
      match.winnerId = match.player2Id;
      advanceByeWinner(match, allMatches, rounds);
    } else if (match.player1Id && match.player2Id) {
      match.status = 'open';
    }
  });

  return {
    format: 'single_elimination',
    totalRounds,
    rounds,
    players: new Map(seededPlayers.map(p => [p.playerId, p])),
    totalParticipants: n
  };
}

/**
 * Generates the order in which seeds are placed into bracket slots.
 * For a 16-player bracket: [1, 16, 8, 9, 5, 12, 4, 13, 3, 14, 6, 11, 7, 10, 2, 15]
 * This ensures 1 vs 16, 8 vs 9 in round 1, etc.
 */
function generateBracketSeedOrder(size: number): number[] {
  if (size === 2) return [1, 2];
  const half = generateBracketSeedOrder(size / 2);
  const result = [];
  for (let i = 0; i < half.length; i++) {
    result.push(half[i]);
    result.push(size + 1 - half[i]);
  }
  return result;
}

function advanceByeWinner(byeMatch: BracketMatch, allMatches, rounds) {
  if (!byeMatch.nextMatchId) return;
  const nextMatch = allMatches.get(byeMatch.nextMatchId);
  const slotInNext = byeMatch.position % 2 === 1 ? 'player1Id' : 'player2Id';
  if (nextMatch && !nextMatch[slotInNext]) {
    nextMatch[slotInNext] = byeMatch.winnerId;
    // Check if this new match is now complete with two players
    if (nextMatch.player1Id && nextMatch.player2Id && nextMatch.status === 'pending') {
      nextMatch.status = 'open';
    }
  }
}

Elo Rating Updates After Match Resolution

Every tournament match updates player Elo ratings using a modified version of the standard Elo formula. The modification accounts for the fact that Ludo tournaments are single-game matches (unlike chess which uses multi-game matches), so the variance per match is higher. The K-factor for tournament play is set at 32 for new players and 16 for established players, producing slightly larger swings than ranked matchmaking while remaining stable over a tournament series.

TypeScript
// Elo Rating System for Tournament Play
// Based on standard Elo with K-factor adjusted for 1-game matches (higher variance)

const K_TOURNAMENT_NEW = 32;   // Players with fewer than 20 tournament games
const K_TOURNAMENT_REG = 16;   // Established tournament players
const K_TOURNAMENT_VET = 8;    // Players with 50+ tournament games (stabilized)
const ELO_SCALE = 400;

interface EloUpdate {
  playerId: string;
  previousRating: number;
  newRating: number;
  delta: number;
  expectedScore: number;
  actualScore: number;
}

function getTournamentKFactor(totalGames: number): number {
  if (totalGames < 20) return K_TOURNAMENT_NEW;
  if (totalGames < 50) return K_TOURNAMENT_REG;
  return K_TOURNAMENT_VET;
}

function expectedTournamentScore(playerRating: number, opponentRating: number): number {
  return 1 / (1 + Math.pow(10, (opponentRating - playerRating) / ELO_SCALE));
}

/**
 * Calculate Elo updates for a tournament match result.
 * actualScore: 1 = win, 0.5 = draw (extremely rare in Ludo), 0 = loss
 */
function calculateTournamentElo(
  winnerRating: number,
  loserRating: number,
  winnerGames: number = 0,
  loserGames: number = 0
): number {
  const K_winner = getTournamentKFactor(winnerGames);
  const K_loser = getTournamentKFactor(loserGames);
  const avgRating = (winnerRating + loserRating) / 2;

  const expectedWinner = expectedTournamentScore(winnerRating, loserRating);
  const actualWinner = 1;   // Win = 1.0

  // Winner gains: K * (1 - expected)
  const winnerDelta = Math.round(K_winner * (actualWinner - expectedWinner));

  // Loser loses the same amount (zero-sum)
  return winnerDelta;
}

function calculateBothPlayerEloUpdates(
  winnerId: string, winnerRating: number, winnerGames: number,
  loserId: string, loserRating: number, loserGames: number
): { winnerUpdate: EloUpdate, loserUpdate: EloUpdate } {
  const delta = calculateTournamentElo(winnerRating, loserRating, winnerGames, loserGames);
  const expected = expectedTournamentScore(winnerRating, loserRating);

  return {
    winnerUpdate: {
      playerId: winnerId,
      previousRating: winnerRating,
      newRating: winnerRating + delta,
      delta,
      expectedScore: expected,
      actualScore: 1
    },
    loserUpdate: {
      playerId: loserId,
      previousRating: loserRating,
      newRating: loserRating - delta,
      delta: -delta,
      expectedScore: 1 - expected,
      actualScore: 0
    }
  };
}

/**
 * Swiss-system pairing algorithm.
 * Matches players with the same (or closest) number of wins in each round.
 * Prevents same-pairing: players who have already played cannot be paired again.
 */
function generateSwissPairings(
  players: Map<string, { playerId: string; wins: number; rating: number; opponents: Set<string> }>,
  round: number
): Array<[string, string]> {
  // Group players by win count
  const byWins = new Map<number, Array<string>>();
  players.forEach((p, id) => {
    if (!byWins.has(p.wins)) byWins.set(p.wins, []);
    byWins.get(p.wins).push(id);
  });

  const pairings = [];
  const paired = new Set<string>();

  // Sort win groups by highest rating within each group
  const sortedWins = [...byWins.keys()].sort((a, b) => b - a);

  for (const winCount of sortedWins) {
    const group = byWins.get(winCount).filter(id => !paired.has(id));

    for (let i = 0; i < group.length; i++) {
      if (paired.has(group[i])) continue;

      // Find best available opponent (closest rating, not previously paired)
      const p1 = players.get(group[i]);
      let bestOpponent = null;
      let bestScore = Infinity;

      for (let j = i + 1; j < group.length; j++) {
        if (paired.has(group[j])) continue;
        const p2 = players.get(group[j]);
        if (p1.opponents.has(p2.playerId)) continue; // No repeat matchups

        const ratingDiff = Math.abs(p1.rating - p2.rating);
        if (ratingDiff < bestScore) {
          bestScore = ratingDiff;
          bestOpponent = group[j];
        }
      }

      if (bestOpponent !== null) {
        pairings.push([group[i], bestOpponent]);
        paired.add(group[i]);
        paired.add(bestOpponent);
      }
    }
  }

  return pairings;
}

Prize Distribution & Payment Webhooks

Once a tournament reaches the COMPLETED state, prizes are calculated according to the distribution schema defined at tournament creation and distributed automatically via webhooks. The prize calculation supports multiple distribution models: percentage-based (top X% of prize pool), fixed-position (defined amounts per position), and hybrid (fixed for top 3, percentage for the rest). Each prize triggers an HTTP webhook to the payment service with a signed payload for verification.

Node.js
// Prize Distribution — calculation and webhook triggers

interface PrizeTier {
  positions: string;       // '1', '2', '3', '1-4', '5-8', '9-16'
  type: string;           // 'percentage' | 'fixed'
  value: number;           // % of pool or absolute amount
  currency: string;        // 'coins' | 'usd' | 'inr'
  perPlayer: boolean;       // If true, divide by number of players in tier
}

interface PrizeAward {
  tournamentId: string;
  playerId: string;
  position: number;
  amount: number;
  currency: string;
  webhookId: string;
  status: 'pending' | 'sent' | 'confirmed' | 'failed';
  sentAt: number | null;
  confirmedAt: number | null;
  retryCount: number;
}

function calculatePrizeDistribution(
  placements: Array<{ playerId: string; position: number; eliminatedRound: number }>,
  prizePool: { total: number; tiers: PrizeTier[]; currency: string }
): PrizeAward[] {
  const awards = [];
  const poolTotal = prizePool.total;

  for (const tier of prizePool.tiers) {
    const positions = parsePositions(tier.positions);
    const playersInTier = placements.filter(p => positions.includes(p.position));

    let tierAmount;
    if (tier.type === 'percentage') {
      tierAmount = Math.floor(poolTotal * (tier.value / 100));
    } else {
      tierAmount = tier.value;
    }

    const perPlayer = tier.perPlayer ? tierAmount / playersInTier.length : tierAmount;

    playersInTier.forEach(placement => {
      awards.push({
        tournamentId: this.id,
        playerId: placement.playerId,
        position: placement.position,
        amount: perPlayer,
        currency: tier.currency || prizePool.currency,
        webhookId: uuidv4(),
        status: 'pending',
        sentAt: null,
        confirmedAt: null,
        retryCount: 0
      });
    });
  }

  return awards.sort((a, b) => a.position - b.position);
}

/**
 * Trigger webhook for prize payment.
 * The payment service must respond with 200 to confirm receipt.
 * Failed webhooks are retried up to 5 times with exponential backoff.
 */
async function triggerPrizeWebhook(award: PrizeAward): Promise<boolean> {
  const payload = {
    webhookId: award.webhookId,
    tournamentId: award.tournamentId,
    playerId: award.playerId,
    position: award.position,
    amount: award.amount,
    currency: award.currency,
    timestamp: Date.now()
  };

  // Sign the payload with HMAC-SHA256 for verification
  const signature = crypto
    .createHmac('sha256', process.env.WEBHOOK_SECRET)
    .update(JSON.stringify(payload))
    .digest('hex');

  try {
    const response = await fetch(`${process.env.PAYMENT_SERVICE_URL}/api/v1/payouts/prize`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Webhook-Signature': signature,
        'X-Webhook-ID': award.webhookId
      },
      body: JSON.stringify(payload),
      signal: AbortSignal.timeout(10000)
    });

    if (response.ok) {
      award.status = 'sent';
      award.sentAt = Date.now();
      console.log(`Prize webhook sent: {award.playerId} - {award.amount} {award.currency}`);
      return true;
    }

    award.status = 'failed';
    award.retryCount++;
    console.error(`Prize webhook failed (attempt {award.retryCount}): {response.status}`);
    return false;
  } catch (err) {
    award.status = 'failed';
    award.retryCount++;
    console.error(`Prize webhook error: {err.message}`);
    return false;
  }
}

// Retry loop for failed webhooks — exponential backoff: 5s, 15s, 45s, 135s, 405s
async function processPrizeRetries(awards: PrizeAward[]) {
  const failed = awards.filter(a => a.status === 'failed' && a.retryCount < 5);
  for (const award of failed) {
    const delay = 5000 * Math.pow(3, award.retryCount - 1);
    await new Promise(r => setTimeout(r, delay));
    await triggerPrizeWebhook(award);
  }
}

Spectator View for Tournament Matches

Tournament spectators need a live, read-only view of ongoing matches. The spectator system for tournaments mirrors the multiplayer room spectator mode but adds tournament-specific features: following a specific player through their bracket run, viewing match history within the tournament, and seeing real-time bracket updates. Spectators receive board state updates with a configurable delay (default 3 seconds) to prevent stream-sniping in competitive formats.

Tournament spectating uses a separate WebSocket namespace (/ws/tournament/{id}/spectate) that authenticates spectators against the tournament's spectator settings. Some tournaments are open-spectator (anyone can watch), while premium or competitive tournaments restrict spectators to tournament participants only.

Match Result Submission & Anti-Cheat

Every match result submitted to the tournament API must be signed by the game server with a server-side secret. The signature field in the result payload is an HMAC-SHA256 of the match outcome, preventing players from fabricating win results. The tournament server validates this signature before accepting the result. Additional anti-cheat checks include: verifying that both players in the match reported consistent results, detecting abnormally short game durations, flagging repeat matchups between the same players in elimination brackets, and monitoring for coordinated seeding manipulation.

Frequently Asked Questions

Launch Your First Ludo Tournament

Get the complete tournament infrastructure: bracket generation, match resolution with Elo, prize distribution with webhooks, and spectator mode — production-ready TypeScript code. Reach out on WhatsApp for the full source package, API credentials, and tournament hosting options.

Chat on WhatsApp