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.
// 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.
// 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.
// 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.
// 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
The system pads the player count to the next power of 2 (e.g., 37 players becomes 64). The lowest-seeded players receive first-round byes â this is automatic because they face each other in the first round. When player A faces player B and B has no opponent, player A gets a bye and advances to the next round without playing. This is fair because bye recipients are always the lowest seeds. For a 37-player bracket (64-slot), 27 players receive byes in round 1, leaving 18 actual matches. The bracket size never exceeds 2x the actual player count, so the tournament structure remains clean.
Swiss-system tiebreakers are applied in order: (1) Number of wins â non-negotiable tiebreaker. (2) Buchholz score â sum of opponents' scores (each opponent's win count). (3) Sonneborn-Berger (SB) score â sum of points from beating each opponent (win = opponent's total wins at time of pairing). (4) Direct encounter â if two players are still tied, the result of their head-to-head match. (5) Rating at tournament start. These tiebreakers are applied automatically and deterministically by the API, producing a unique ranking order for prize distribution.
Entry fees are escrowed at tournament creation in a holding account. If a tournament transitions to CANCELLED state before it begins, all entry fees are automatically refunded via the payment webhook system within 24 hours. If a tournament is ABORTED mid-way (due to server failure), the system determines the last valid state â if more than 50% of matches were completed, prizes are distributed proportionally based on the last confirmed bracket state. If less than 50% was complete, all entry fees are refunded. Every state transition triggers an audit log entry for dispute resolution.
The prize webhook retry system uses exponential backoff (5s, 15s, 45s, 135s, 405s) up to 5 attempts. If all 5 retries fail, the prize is marked as failed and an alert is sent to the operations dashboard for manual intervention. Prize awards are persisted to durable storage (PostgreSQL) immediately after state transition to COMPLETED, so no prizes are lost even if the payment service is completely down. The webhook signature includes a timestamp, so old webhook payloads cannot be replayed even if captured.
Yes â tournament performance and ranked matchmaking ratings are tracked separately but cross-referenced. A player with a tournament rating of 1800 who consistently places in top-4 of weekly championships will naturally find easier opponents in the matchmaking queue (which uses the same underlying rating scale). The REST API exposes both ratings independently, and the advanced matchmaking guide covers how the system blends both signals for optimal pairing.
A disconnect during a tournament match triggers a grace period (default 60 seconds). During this window, the game pauses on the server side and remaining players see a "waiting for opponent" indicator. If the player reconnects, the game resumes from the exact same state. If the grace period expires, the disconnected player forfeits the match and their opponent advances. This is enforced server-side â the game server records the disconnect timestamp and refuses to accept moves from the disconnected player's socket until reconnection. Disconnect forfeits count as losses for Elo purposes and do not trigger prize refunds for the disconnected player.
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