Skip to main content

Auction Engine - Detailed Design Document

Architecture Version: 3.0 | Last Updated: January 2026 Pattern References: Real-Time Bidding Systems, WebSocket Architecture, Event Sourcing


1. Executive Overview​

The Auction Engine brings the thrill of IPL auctions to fantasy sports. Instead of simply picking players from a list, users participate in live auctions where they bid against each other for players. This creates excitement, strategy, and a completely different fantasy experience.

Imagine sitting in a virtual auction room, watching as Virat Kohli comes up for bidding, and you have to decide - do you spend big now, or save your budget for later? That's the experience the Auction Engine delivers.

Key Capabilities:

  • Real-time bidding with sub-100ms latency
  • Multiple auction formats (Live, Silent, Draft, Salary Cap)
  • AI-powered agents that can bid on behalf of users
  • Concurrent auctions supporting thousands of simultaneous rooms
  • Fair play guarantees through cryptographic bid ordering

2. Core Architecture​

2.1 High-Level System Design​

What this diagram shows: The Auction Engine uses a WebSocket-first architecture for real-time bidding. All clients connect through the WebSocket Gateway, which uses Redis Pub/Sub for horizontal scaling. The Bid Processor validates and sequences bids, while the Timer Service manages the "Going once... Going twice... SOLD!" countdown. All events are persisted to Kafka for durability and replay.


3. Core Concepts​

What this diagram shows: The Auction Engine supports multiple auction formats. Live Auction mimics the IPL mega auction with real-time bidding. Silent Auction lets users submit sealed bids. Draft Pick is turn-based selection. Salary Cap Draft combines picking with budget constraints. Each format has its own bidding mechanics and rules.


Auction States & Lifecycle​

Every auction goes through a carefully managed lifecycle:

What this diagram shows: This is the heartbeat of a live auction. A player is nominated, bidding opens, and bids flow in. When bidding slows, we enter the "Going once... Going twice... SOLD!" sequence. Any new bid resets back to active bidding. If no one bids at base price, the player goes unsold.


Data Models​

Auction Entities​

What this diagram shows: The data model tracks everything about an auction. AUCTION defines the auction configuration. AUCTION_PARTICIPANT tracks each user's remaining budget and squad. AUCTION_PLAYER tracks each cricket player's status in the auction. BID records every bid placed, enabling full audit trails.


Live Auction Flow​

Here's what happens during a live auction, from player nomination to sale:

What this diagram shows: This is the real-time dance of a live auction. The auctioneer bot nominates players, the engine validates bids, and WebSocket broadcasts keep everyone synchronized. Notice how P3's last-second bid reset the countdown - just like a real auction!


8. Bidding Rules & Validation​

8.1 Bid Validation Pipeline​

8.2 Validation Rules Implementation​

interface BidValidationRule {
name: string;
validate(context: BidContext): Promise<ValidationResult>;
}

class BidValidator {
private rules: BidValidationRule[] = [
new AuctionActiveRule(),
new PlayerUpForBidRule(),
new ParticipantValidRule(),
new SufficientPurseRule(),
new BidAmountRule(),
new IncrementRule(),
new SquadLimitRule(),
new PositionLimitRule(),
];

async validate(bid: Bid, context: AuctionContext): Promise<ValidationResult> {
const bidContext: BidContext = {
bid,
auction: context.auction,
participant: context.participant,
currentPlayer: context.currentPlayer,
currentHighBid: context.currentHighBid,
};

for (const rule of this.rules) {
const result = await rule.validate(bidContext);
if (!result.valid) {
return {
valid: false,
error: result.error,
rule: rule.name,
};
}
}

return { valid: true };
}
}

// Example: Sufficient Purse Rule
class SufficientPurseRule implements BidValidationRule {
name = 'SufficientPurse';

async validate(context: BidContext): Promise<ValidationResult> {
const { bid, participant, auction } = context;

// Calculate minimum required purse after this bid
const remainingSlots = auction.squadSize - participant.playersBought - 1;
const minReserve = remainingSlots * auction.minBidAmount;
const availablePurse = participant.remainingPurse - minReserve;

if (bid.amount > availablePurse) {
return {
valid: false,
error: `Insufficient purse. Max bid: ${availablePurse}`,
};
}

return { valid: true };
}
}

9. Real-Time Communication​

9.1 WebSocket Architecture​

9.2 Message Types​

// WebSocket Message Protocol
type AuctionMessage =
| PlayerNominatedMessage
| BidPlacedMessage
| BidRejectedMessage
| CountdownMessage
| PlayerSoldMessage
| PlayerUnsoldMessage
| AuctionStateMessage
| ParticipantUpdateMessage;

interface PlayerNominatedMessage {
type: 'PLAYER_NOMINATED';
payload: {
playerId: string;
playerName: string;
basePrice: number;
position: string;
stats: PlayerStats;
nominationOrder: number;
};
}

interface BidPlacedMessage {
type: 'BID_PLACED';
payload: {
bidId: string;
participantId: string;
participantName: string;
amount: number;
timestamp: number;
isLeading: boolean;
};
}

interface CountdownMessage {
type: 'COUNTDOWN';
payload: {
stage: 'GOING_ONCE' | 'GOING_TWICE' | 'SOLD';
currentBid: number;
leadingParticipant: string;
secondsRemaining: number;
};
}

9.3 Connection Management​

class AuctionConnectionManager {
private connections: Map<string, WebSocket> = new Map();
private rooms: Map<string, Set<string>> = new Map();
private redis: Redis;

async joinRoom(connectionId: string, auctionId: string): Promise<void> {
// Add to local room
if (!this.rooms.has(auctionId)) {
this.rooms.set(auctionId, new Set());
}
this.rooms.get(auctionId)!.add(connectionId);

// Subscribe to Redis channel for this auction
await this.redis.subscribe(`auction:${auctionId}`, (message) => {
this.broadcastToRoom(auctionId, message);
});

// Send current state to new participant
const state = await this.getAuctionState(auctionId);
this.sendToConnection(connectionId, {
type: 'AUCTION_STATE',
payload: state,
});
}

async broadcastBid(auctionId: string, bid: BidPlacedMessage): Promise<void> {
// Publish to Redis for cross-server broadcast
await this.redis.publish(`auction:${auctionId}`, JSON.stringify(bid));
}

private broadcastToRoom(auctionId: string, message: string): void {
const room = this.rooms.get(auctionId);
if (!room) return;

for (const connectionId of room) {
this.sendToConnection(connectionId, JSON.parse(message));
}
}
}

10. Timer & Countdown Service​

10.1 Countdown State Machine​

10.2 Timer Implementation​

class AuctionTimer {
private timers: Map<string, NodeJS.Timeout> = new Map();
private states: Map<string, CountdownState> = new Map();

private readonly config = {
ACTIVE_TIMEOUT: 5000, // 5 seconds to GOING_ONCE
GOING_ONCE_TIMEOUT: 3000, // 3 seconds to GOING_TWICE
GOING_TWICE_TIMEOUT: 2000, // 2 seconds to SOLD
};

startCountdown(auctionId: string, playerId: string): void {
this.states.set(playerId, 'ACTIVE');
this.scheduleTransition(auctionId, playerId, 'ACTIVE');
}

onBidReceived(auctionId: string, playerId: string): void {
// Cancel existing timer
this.cancelTimer(playerId);

// Reset to ACTIVE state
this.states.set(playerId, 'ACTIVE');
this.scheduleTransition(auctionId, playerId, 'ACTIVE');
}

private scheduleTransition(
auctionId: string,
playerId: string,
currentState: CountdownState
): void {
const timeout = this.getTimeout(currentState);
const nextState = this.getNextState(currentState);

const timer = setTimeout(async () => {
if (nextState === 'SOLD') {
await this.handleSold(auctionId, playerId);
} else {
this.states.set(playerId, nextState);
await this.broadcastCountdown(auctionId, playerId, nextState);
this.scheduleTransition(auctionId, playerId, nextState);
}
}, timeout);

this.timers.set(playerId, timer);
}

private getTimeout(state: CountdownState): number {
switch (state) {
case 'ACTIVE': return this.config.ACTIVE_TIMEOUT;
case 'GOING_ONCE': return this.config.GOING_ONCE_TIMEOUT;
case 'GOING_TWICE': return this.config.GOING_TWICE_TIMEOUT;
default: return 0;
}
}
}

11. AI Agent Integration​

11.1 Agent Architecture​

11.2 Agent Strategy Implementation​

interface AuctionAgent {
id: string;
strategy: AgentStrategy;
evaluate(player: AuctionPlayer, context: AgentContext): BidDecision;
}

interface AgentStrategy {
name: string;
riskTolerance: number; // 0-1
starPlayerPremium: number; // Multiplier for top players
positionPriorities: Record<Position, number>;
budgetAllocation: BudgetAllocation;
}

class BalancedAgent implements AuctionAgent {
strategy: AgentStrategy = {
name: 'Balanced',
riskTolerance: 0.5,
starPlayerPremium: 1.3,
positionPriorities: {
BATSMAN: 0.3,
BOWLER: 0.3,
ALL_ROUNDER: 0.25,
WICKET_KEEPER: 0.15,
},
budgetAllocation: {
starPlayers: 0.4, // 40% for top 3 players
corePlayers: 0.4, // 40% for core squad
fillers: 0.2, // 20% for remaining slots
},
};

evaluate(player: AuctionPlayer, context: AgentContext): BidDecision {
// Calculate player value based on stats and form
const baseValue = this.calculatePlayerValue(player);

// Adjust for position need
const positionNeed = this.getPositionNeed(player.position, context);
const adjustedValue = baseValue * positionNeed;

// Apply strategy modifiers
const maxBid = this.calculateMaxBid(adjustedValue, context);

// Decide whether to bid
const currentBid = context.currentHighBid || player.basePrice;

if (currentBid < maxBid) {
return {
shouldBid: true,
amount: this.calculateBidAmount(currentBid, maxBid, context),
confidence: this.calculateConfidence(player, context),
};
}

return { shouldBid: false };
}

private calculateBidAmount(
currentBid: number,
maxBid: number,
context: AgentContext
): number {
// Bid increment based on strategy
const increment = this.getBidIncrement(currentBid);
const proposedBid = currentBid + increment;

// Add randomness to avoid predictable patterns
const variance = (Math.random() - 0.5) * 0.1 * increment;

return Math.min(proposedBid + variance, maxBid);
}
}

12. Concurrency & Fairness​

12.1 Bid Ordering with Timestamps​

12.2 Distributed Lock for Bid Processing​

class BidSequencer {
private redis: Redis;

async processBid(auctionId: string, playerId: string, bid: Bid): Promise<BidResult> {
const lockKey = `auction:${auctionId}:player:${playerId}:lock`;

// Acquire distributed lock
const lock = await this.redis.set(lockKey, bid.id, 'NX', 'PX', 100);

if (!lock) {
// Another bid is being processed, queue this one
return this.queueBid(auctionId, playerId, bid);
}

try {
// Get current state
const currentBid = await this.getCurrentHighBid(auctionId, playerId);

// Validate bid
if (bid.amount <= currentBid) {
return { success: false, reason: 'Bid too low' };
}

// Process bid atomically
await this.redis.multi()
.set(`auction:${auctionId}:player:${playerId}:high_bid`, bid.amount)
.set(`auction:${auctionId}:player:${playerId}:high_bidder`, bid.participantId)
.lpush(`auction:${auctionId}:player:${playerId}:bids`, JSON.stringify(bid))
.exec();

return { success: true, bid };
} finally {
// Release lock
await this.redis.del(lockKey);
}
}
}

13. Event Sourcing for Auctions​

13.1 Event Store Design​

13.2 Event Definitions​

// Auction Events
type AuctionEvent =
| AuctionCreatedEvent
| ParticipantJoinedEvent
| PlayerNominatedEvent
| BidPlacedEvent
| BidRejectedEvent
| PlayerSoldEvent
| PlayerUnsoldEvent
| AuctionCompletedEvent;

interface BidPlacedEvent {
type: 'BID_PLACED';
aggregateId: string; // auctionId
sequence: number;
timestamp: Date;
payload: {
bidId: string;
playerId: string;
participantId: string;
amount: number;
previousHighBid: number | null;
previousHighBidder: string | null;
};
}

interface PlayerSoldEvent {
type: 'PLAYER_SOLD';
aggregateId: string;
sequence: number;
timestamp: Date;
payload: {
playerId: string;
playerName: string;
soldTo: string;
soldToName: string;
finalPrice: number;
totalBids: number;
biddingDuration: number;
};
}

// Event Store Implementation
class AuctionEventStore {
async append(auctionId: string, events: AuctionEvent[]): Promise<void> {
const topic = `auction.events.${auctionId}`;

for (const event of events) {
await this.kafka.produce(topic, {
key: auctionId,
value: JSON.stringify(event),
headers: {
eventType: event.type,
timestamp: event.timestamp.toISOString(),
},
});
}
}

async replay(auctionId: string): Promise<AuctionState> {
const events = await this.kafka.consume(`auction.events.${auctionId}`);

let state = AuctionState.initial();
for (const event of events) {
state = state.apply(event);
}

return state;
}
}

14. Scalability & Performance​

14.1 Horizontal Scaling Architecture​

14.2 Performance Targets​

MetricTargetMeasurement
Bid Latency (p50)< 50msTime from bid to broadcast
Bid Latency (p99)< 200msWorst case latency
Concurrent Auctions1,000+Simultaneous live auctions
Participants per Auction100+Users in single auction
Bids per Second10,000+System-wide throughput
WebSocket Connections100,000+Concurrent connections

14.3 Optimization Strategies​

// Connection pooling for Redis
const redisPool = new GenericPool({
create: () => new Redis(config),
destroy: (client) => client.quit(),
min: 10,
max: 100,
});

// Batch bid processing
class BatchBidProcessor {
private queue: Bid[] = [];
private processing = false;

async addBid(bid: Bid): Promise<void> {
this.queue.push(bid);

if (!this.processing) {
this.processing = true;
await this.processBatch();
}
}

private async processBatch(): Promise<void> {
while (this.queue.length > 0) {
// Process up to 100 bids at once
const batch = this.queue.splice(0, 100);

// Group by auction for efficient processing
const byAuction = this.groupByAuction(batch);

// Process each auction's bids in parallel
await Promise.all(
Object.entries(byAuction).map(([auctionId, bids]) =>
this.processAuctionBids(auctionId, bids)
)
);
}

this.processing = false;
}
}

15. Monitoring & Observability​

15.1 Key Metrics​

const auctionMetrics = {
// Bid Processing
bidLatency: new Histogram({
name: 'auction_bid_latency_seconds',
help: 'Bid processing latency',
labelNames: ['auction_type'],
buckets: [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1],
}),

bidsPerSecond: new Counter({
name: 'auction_bids_total',
help: 'Total bids processed',
labelNames: ['auction_id', 'status'],
}),

// WebSocket
activeConnections: new Gauge({
name: 'auction_websocket_connections',
help: 'Active WebSocket connections',
labelNames: ['server_id'],
}),

messageLatency: new Histogram({
name: 'auction_message_latency_seconds',
help: 'WebSocket message delivery latency',
buckets: [0.005, 0.01, 0.025, 0.05, 0.1],
}),

// Auction State
activeAuctions: new Gauge({
name: 'auction_active_total',
help: 'Number of active auctions',
labelNames: ['type'],
}),

playersPerAuction: new Histogram({
name: 'auction_players_total',
help: 'Players per auction',
buckets: [10, 25, 50, 100, 200],
}),
};

15.2 Distributed Tracing​


16. Error Handling & Recovery​

16.1 Failure Scenarios​

ScenarioDetectionRecovery
WebSocket disconnectHeartbeat timeoutAuto-reconnect with state sync
Bid processing failureException caughtRetry with exponential backoff
Redis unavailableHealth checkFailover to replica
Auction state corruptionChecksum mismatchReplay from event store

16.2 Reconnection Protocol​

class AuctionReconnectionHandler {
async handleReconnect(
connectionId: string,
auctionId: string,
lastEventId: string
): Promise<void> {
// Get events since last known state
const missedEvents = await this.eventStore.getEventsSince(
auctionId,
lastEventId
);

// Send missed events to client
for (const event of missedEvents) {
await this.sendToConnection(connectionId, event);
}

// Send current state snapshot
const currentState = await this.getAuctionState(auctionId);
await this.sendToConnection(connectionId, {
type: 'STATE_SYNC',
payload: currentState,
});
}
}

17. Security Considerations​

17.1 Anti-Cheating Measures​

17.2 Rate Limiting Configuration​

const auctionRateLimits = {
// Bids per participant per auction
bidsPerMinute: {
limit: 60,
window: 60000,
keyGenerator: (req) => `${req.user.id}:${req.auctionId}`,
},

// Connections per user
connectionsPerUser: {
limit: 3,
keyGenerator: (req) => req.user.id,
},

// Global bid rate
globalBidsPerSecond: {
limit: 10000,
window: 1000,
},
};

18. Testing Strategy​

18.1 Load Testing Scenarios​

describe('Auction Load Tests', () => {
it('should handle 100 concurrent bidders', async () => {
const auction = await createTestAuction({ maxParticipants: 100 });

// Simulate 100 concurrent bidders
const bidders = Array.from({ length: 100 }, (_, i) =>
createTestBidder(`bidder-${i}`)
);

// All bidders join simultaneously
await Promise.all(bidders.map(b => b.join(auction.id)));

// Simulate bidding war
const player = await nominatePlayer(auction.id);

// 100 bids in rapid succession
const bidPromises = bidders.map((b, i) =>
b.placeBid(player.id, player.basePrice + i)
);

const results = await Promise.all(bidPromises);

// Verify exactly one winner
const winners = results.filter(r => r.isWinning);
expect(winners).toHaveLength(1);
});

it('should maintain sub-100ms latency under load', async () => {
const latencies: number[] = [];

// Place 1000 bids and measure latency
for (let i = 0; i < 1000; i++) {
const start = performance.now();
await placeBid(testAuction.id, testPlayer.id, basePrice + i);
latencies.push(performance.now() - start);
}

const p99 = percentile(latencies, 99);
expect(p99).toBeLessThan(100);
});
});

19. Integration with Contest Engine​

19.1 Auction-to-Contest Flow​


20. Future Enhancements​

20.1 Roadmap​

PhaseFeatureDescription
Q1 2026Voice BiddingVoice commands for bids
Q2 2026AR Auction RoomImmersive AR experience
Q3 2026Cross-Platform SyncSeamless mobile/web transition
Q4 2026AI AuctioneerAI-powered auction host

21. Appendix​

21.1 Glossary​

TermDefinition
Base PriceMinimum starting bid for a player
PurseBudget available to a participant
RTMRight to Match - ability to match winning bid
UnsoldPlayer who received no bids at base price

21.2 References​


Document Version: 3.0.0 | Last Updated: January 2026