A deep-dive technical guide to securing Ludo game APIs. Covers server-authoritative architecture, move validation, rate limiting, JWT authentication, cheat detection patterns, and replay systems — with production-ready code for every mitigation strategy.
Ludo games are deceptively simple yet strategically deep, which makes them fertile ground for cheaters. Before building defenses, you need to understand the specific attack surface a Ludo API exposes. Generic web security covers SQL injection and XSS — but Ludo games have a unique threat model rooted in the game rules themselves.
Attacker intercepts the API request and replaces the dice value (1-6) with a desired number. Completes a token's path without valid dice rolls.
→ Server-side dice generation with cryptographic randomness
Manipulating the game state so a token lands on an opponent's safe square, bypassing the "safe zone" rule. In real Ludo, a token can only be captured outside the final colored path.
→ Enforce safe-zone logic server-side on every move
Sending a rapid-fire sequence of 20 moves in one API call, each requiring different dice values, to simulate a "lucky streak" that clears the board.
→ Server validates move sequence against actual dice roll history
A Ludo player should only move one token per turn. A client exploit moves multiple tokens simultaneously by sending parallel requests with the same session token.
→ Server enforces turn-locking and single-move-per-turn logic
In standard Ludo, only one token can occupy a square. Stacking (multiple tokens on same square) is illegal except in home columns. Exploit sends a move that places 2 tokens on the same square, bypassing capture.
→ Board state validation on every move commit
Claiming all 4 tokens have reached home before the actual position is achieved, triggering a false win condition and awarding incorrect rewards.
→ Server independently evaluates win condition from board state
Automated scripts playing optimal moves at superhuman speed (e.g., 10 moves/second) to farm coins against human players. Not technically cheating game rules, but ruins fairness.
→ Move timing analysis, CAPTCHA challenges, per-user rate limits
Two accounts controlled by the same person agree to let one win by self-eliminating, farming rewards across accounts. Requires device fingerprinting and behavioral correlation.
→ Device fingerprinting, behavioral analysis, account linkage detection
Never, under any circumstances, trust dice values from the client. In a server-authoritative model, the client doesn't even send a dice request — the server generates the roll and pushes it to all clients via WebSocket. But even in a REST-based polling model, the flow is: client sends POST /games/{id}/roll → server generates → server responds with value → server records in history.
Use Node.js's built-in crypto.randomInt() (available since Node 14.17.0). It's backed by crypto.randomBytes() and is suitable for security-sensitive operations. Do not use Math.random() — it is a linear congruential generator that is predictable when an attacker has partial state knowledge.
import crypto from 'crypto';
interface DiceRoll {
value: number; // 1-6
seed: string; // UUID v4 — uniquely identifies this roll
timestamp: number;
previousHash: string; // SHA-256 of previous roll — chain integrity
rollHash: string; // SHA-256(value + seed + timestamp + previousHash)
}
// Build a verifiable dice chain — similar to blockchain merkle tree
// Allows auditors to verify the entire dice history hasn't been altered
class DiceChain {
private headHash: string;
constructor(initialHash: string = 'GENESIS') {
this.headHash = initialHash;
}
roll(): DiceRoll {
const value = crypto.randomInt(1, 7);
const seed = crypto.randomUUID();
const timestamp = Date.now();
const rollData = `${value}:${seed}:${timestamp}:${this.headHash}`;
const rollHash = crypto.createHash('sha256').update(rollData).digest('hex');
// Store the current roll's hash as the new head
// Each roll's hash includes the previous roll's hash → tamper-evident chain
this.headHash = rollHash;
return { value, seed, timestamp, previousHash: this.headHash, rollHash };
}
// Verify a historical roll hasn't been altered
verifyRoll(roll: DiceRoll, expectedPreviousHash: string): boolean {
const computed = `${roll.value}:${roll.seed}:${roll.timestamp}:${expectedPreviousHash}`;
const computedHash = crypto.createHash('sha256').update(computed).digest('hex');
return computedHash === roll.rollHash;
}
}
// Usage in game server
const gameDiceChains: Map<GameId, DiceChain> = new Map();
function rollDiceForGame(gameId: GameId): DiceRoll {
let chain = gameDiceChains.get(gameId);
if (!chain) {
// Seed the chain with game ID + creation time to prevent cross-game correlation
const genesisHash = crypto
.createHash('sha256')
.update(`${gameId}:${Date.now()}`)
.digest('hex');
chain = new DiceChain(genesisHash);
gameDiceChains.set(gameId, chain);
}
return chain.roll();
}
The dice chain pattern above creates a tamper-evident log. Each roll's hash includes the previous roll's hash. If an attacker tries to alter a roll in history, the hash chain breaks and the tampering is detectable. Store this chain durably (database, append-only log) and you have cryptographic proof of fair dice for dispute resolution. This is especially valuable for real-money Ludo games where players may dispute "bad luck."
Move validation is where game logic meets security. Every single move request must pass through a rigorous validation pipeline. The validation is not just "is this a legal move?" — it is "is this move consistent with the entire game history up to this point?"
The move validator checks eight distinct conditions in sequence. If any check fails, the move is rejected and the incident is logged for potential ban review.
interface MoveContext {
game: GameState;
playerId: PlayerId;
tokenId: number;
diceValue: number; // from server's canonical dice state
gameVersion: number; // client's claimed version for optimistic locking
}
interface ValidationResult {
valid: boolean;
error?: string;
threatLevel: 'none' | 'suspicious' | 'cheat' | 'critical';
logs: ValidationLogEntry[];
}
interface ValidationLogEntry {
check: string;
passed: boolean;
detail: string;
executionMs: number;
}
async function validateMove(ctx: MoveContext): Promise<ValidationResult> {
const logs: ValidationLogEntry[] = [];
const game = ctx.game;
const measure = (check: string, fn: () => boolean | Promise<boolean>): boolean => {
const start = Date.now();
const result = typeof fn === 'function' ? fn() : fn;
logs.push({ check, passed: !!result, detail: '', executionMs: Date.now() - start });
return !!result;
};
// ============ CHECK 1: Optimistic Version Lock ============
if (!measure('version_check', () => ctx.gameVersion === game.version)) {
return { valid: false, error: 'Stale game state. Re-sync required.', threatLevel: 'suspicious', logs };
}
// ============ CHECK 2: Turn Order ============
if (!measure('turn_order', () => game.currentTurn === ctx.playerId)) {
return {
valid: false,
error: `Turn violation. Expected ${game.currentTurn}, got ${ctx.playerId}`,
threatLevel: 'critical', // implies race condition or session hijacking
logs,
};
}
// ============ CHECK 3: Dice Has Been Rolled ============
if (!measure('dice_rolled', () => game.dice.value !== null)) {
return {
valid: false,
error: 'No dice roll on record. Roll before moving.',
threatLevel: 'cheat',
logs,
};
}
// ============ CHECK 4: Dice Rolled by Correct Player ============
if (!measure('dice_owner', () => game.dice.rolledBy === ctx.playerId)) {
return {
valid: false,
error: 'Dice was rolled by another player.',
threatLevel: 'cheat',
logs,
};
}
// ============ CHECK 5: Token Exists and Belongs to Player ============
const player = game.players[ctx.playerId];
const token = player.tokens[ctx.tokenId];
if (!measure('token_ownership', () => token !== undefined)) {
return {
valid: false,
error: `Invalid token ID ${ctx.tokenId} for player ${ctx.playerId}`,
threatLevel: 'critical',
logs,
};
}
// ============ CHECK 6: Move Legality ============
const moveCheck = checkMoveLegality(game, player, token, ctx.diceValue);
if (!measure('move_legality', () => moveCheck.legal)) {
return {
valid: false,
error: `Illegal move: ${moveCheck.reason}`,
threatLevel: 'cheat',
logs,
};
}
// ============ CHECK 7: Six-Rule (roll 6 to exit home AND to get another turn) ============
// In standard Ludo: a 6 lets you roll again AND can release a token from home
// But you cannot move the same token twice in one turn
const sixRuleCheck = checkSixRule(game, player, token, ctx.diceValue, logs);
if (!measure('six_rule', () => sixRuleCheck.valid)) {
return {
valid: false,
error: sixRuleCheck.reason,
threatLevel: 'cheat',
logs,
};
}
// ============ CHECK 8: Anti-Speed (unreasonable move timing) ============
const timingCheck = checkMoveTiming(game, player, ctx);
if (!measure('timing_check', () => timingCheck.valid)) {
logs.push({
check: 'timing_check',
passed: false,
detail: `Move arrived ${timingCheck.msSinceLastMove}ms after last move (threshold: 200ms human minimum)`,
executionMs: 0,
});
// Downgrade to suspicious — could be fast human, could be bot
return {
valid: true, // Allow the move but flag it
error: `Move flagged for timing review`,
threatLevel: 'suspicious',
logs,
};
}
return { valid: true, threatLevel: 'none', logs };
}
// The actual Ludo move logic — the heart of the game rules
function checkMoveLegality(
game: GameState,
player: PlayerState,
token: TokenPosition,
dice: number
): { legal: boolean; reason: string } {
const color = getPlayerColor(player.id);
const homeEntrySquare = LUDO_CONFIG[color].homeEntrySquare; // e.g., Red=51, Green=11
const homeColumnLength = 6;
switch (token.zone) {
case 'home':
// Can only leave home on a roll of 6
if (dice !== 6) return { legal: false, reason: 'Token in home requires roll of 6 to exit' };
return { legal: true, reason: '' };
case 'start':
if (dice === 6) {
return { legal: true, reason: '' };
}
return { legal: false, reason: 'Token at start requires roll of 6 to enter board' };
case 'board': {
const currentSquare = token.square!;
const newSquare = (currentSquare + dice) % 52;
// Check if new position lands in opponent's safe square
if (isOpponentSafeSquare(newSquare, color)) {
return { legal: false, reason: `Cannot capture on safe square ${newSquare}` };
}
// Check if passes or lands on own home entry
if (newSquare === homeEntrySquare || newSquare > homeEntrySquare) {
const stepsIntoHome = newSquare - homeEntrySquare;
if (stepsIntoHome <= dice) {
if (stepsIntoHome === homeColumnLength) {
return { legal: false, reason: 'Token must enter home column' };
}
return { legal: true, reason: '' };
}
}
return { legal: true, reason: '' };
}
case 'home-column': {
const homeIdx = token.homeColumnIndex!;
const newHomeIdx = homeIdx + dice;
if (newHomeIdx > homeColumnLength) {
return { legal: false, reason: 'Cannot overshoot home column' };
}
return { legal: true, reason: '' };
}
case 'finished':
return { legal: false, reason: 'Token has already finished' };
default:
return { legal: false, reason: 'Unknown token zone' };
}
}
Ludo's capture rules are nuanced and frequently exploited. A token can capture an opponent's token by landing on their exact square, sending it back to home. But landing on a safe square (marked stars in traditional Ludo: squares 1, 9, 14, 22, 27, 35, 40, 48) — or inside a home column — results in no capture.
// Safe squares for each color (the colored start squares are safe for their owner)
const SAFE_SQUARES = new Set([1, 9, 14, 22, 27, 35, 40, 48]);
function isSquareSafe(square: number, movingPlayerColor: string, opponentColor: string): boolean {
// Star squares are always safe from capture
if (SAFE_SQUARES.has(square)) return true;
// Each color's start square is safe only for its own tokens
// Green starts at 0, Yellow at 13, Red at 26, Blue at 39
const colorStartSquares: Record<string, number> = {
green: 0, yellow: 13, red: 26, blue: 39,
};
if (square === colorStartSquares[movingPlayerColor]) return true;
return false;
}
function resolveCollisions(
game: GameState,
movingPlayerId: PlayerId,
targetSquare: number,
token: TokenPosition
): CollisionResult {
const movingColor = getPlayerColor(movingPlayerId);
const opponentIds = getOpponentIds(movingPlayerId);
const captures: CapturedToken[] = [];
// Only check collisions on the main board track, not home columns
if (token.zone === 'board') {
for (const oppId of opponentIds) {
const oppTokens = game.players[oppId].tokens;
for (const oppToken of oppTokens) {
if (oppToken.zone === 'board' && oppToken.square === targetSquare) {
// Collision detected — check if target square is safe
if (!isSquareSafe(targetSquare, movingColor, getPlayerColor(oppId))) {
// Capture the opponent's token
captures.push({
capturedPlayerId: oppId,
capturedTokenId: oppToken.tokenId,
fromSquare: targetSquare,
atTimestamp: Date.now(),
});
oppToken.zone = 'home';
oppToken.square = null;
oppToken.homeColumnIndex = null;
// Log the capture in turn history for replay
game.turnHistory.push({
type: 'CAPTURE',
playerId: movingPlayerId,
capturedPlayerId: oppId,
capturedTokenId: oppToken.tokenId,
square: targetSquare,
timestamp: Date.now(),
gameVersion: game.version,
});
}
}
}
}
}
return { captures };
}
Rate limiting in a Ludo API serves three distinct purposes: preventing brute-force dice manipulation (if any dice logic ever reaches the client), blocking bot-driven gameplay, and protecting against DDoS. A three-tier rate limiting strategy covers each vector.
The outermost layer limits requests from any single IP address using a sliding window algorithm. This is your first line of defense against volumetric attacks and bot farms.
import Redis from 'ioredis';
// Sliding window rate limiter using Redis sorted sets
// More accurate than fixed window — prevents burst at window boundaries
class SlidingWindowRateLimiter {
constructor(private redis: Redis) {}
async checkLimit(
key: string,
limit: number,
windowMs: number
): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
const now = Date.now();
const windowStart = now - windowMs;
const fullKey = `ratelimit:${key}`;
const pipeline = this.redis.pipeline();
// Remove entries outside the current window
pipeline.zremrangebyscore(fullKey, 0, windowStart);
// Count current entries in window
pipeline.zcard(fullKey);
// Add the current request
pipeline.zadd(fullKey, now, `${now}:${Math.random()}`);
// Set expiry on the key (cleanup)
pipeline.pexpire(fullKey, windowMs + 1000);
const results = await pipeline.exec();
const count = results![1][1] as number;
const allowed = count < limit;
const remaining = Math.max(0, limit - count - 1);
const resetAt = now + windowMs;
if (!allowed) {
// Remove the request we just added since it's not allowed
await this.redis.zremrangebyscore(fullKey, now, now);
}
return { allowed, remaining, resetAt };
}
}
// Express middleware factory
function createRateLimitMiddleware(limiter: SlidingWindowRateLimiter) {
return (options: {
windowMs: number;
limit: number;
keyGenerator: (req: Request) => string;
skipSuccessfulRequests?: boolean;
}) => {
return async (req: Request, res: Response, next: NextFunction) => {
const key = options.keyGenerator(req);
const result = await limiter.checkLimit(key, options.limit, options.windowMs);
res.setHeader('X-RateLimit-Limit', options.limit);
res.setHeader('X-RateLimit-Remaining', result.remaining);
res.setHeader('X-RateLimit-Reset', Math.ceil(result.resetAt / 1000));
if (!result.allowed) {
return res.status(429).json({
error: 'Too Many Requests',
retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
code: 'RATE_LIMIT_EXCEEDED',
});
}
next();
};
};
}
// ============ CONCRETE MIDDLEWARE INSTANCES ============
const limiter = new SlidingWindowRateLimiter(redis);
// Tier 1: Global IP rate limit — 120 requests per minute per IP
// Covers all endpoints — protects against DDoS and brute force
const globalIpLimit = createRateLimitMiddleware(limiter)({
windowMs: 60_000,
limit: 120,
keyGenerator: (req) => `global:ip:${req.ip}`,
});
// Tier 2: Per-user game action limit — 8 moves per minute per user per game
// Ludo has a maximum of ~2 moves per 10 seconds for fast play
// 8/min = 1 move every 7.5 seconds — far slower than any human
const gameActionLimit = createRateLimitMiddleware(limiter)({
windowMs: 60_000,
limit: 8,
keyGenerator: (req) => `game:action:${req.gameId}:${req.userId}`,
});
// Tier 3: Per-endpoint strict limit — 2 dice rolls per 5 seconds
// In real Ludo, you roll once per turn (or twice max with a 6)
// This catches bots that hammer the roll endpoint
const diceRollLimit = createRateLimitMiddleware(limiter)({
windowMs: 5_000,
limit: 2,
keyGenerator: (req) => `dice:roll:${req.gameId}:${req.userId}`,
});
// Apply middleware in order (most specific last so it runs closest to the handler)
// app.use(globalIpLimit); // Runs first — global protection
// app.use(gameActionLimit); // Runs second — per-game protection
// app.use(diceRollLimit); // Runs third — per-action protection
A human Ludo player, even at maximum speed, takes at least 4-5 seconds to decide which token to move, observe the dice, and click. That works out to roughly 10-15 moves per minute maximum. But 8/min accounts for network latency variations, mobile users, and the occasional quick re-roll. Set it below the human maximum to catch bots that play faster than physics allows.
Count-based rate limits are bypassable with slow, persistent bots. Supplement them with behavioral analysis: flag accounts that make perfectly optimal moves 100% of the time, or whose move patterns match known bot engines. This requires storing move vectors and running statistical analysis — see the Cheat Detection Patterns section.
Every API request to a Ludo game endpoint must be authenticated. For game servers where low latency is critical, JWTs are preferable to session cookies — they avoid a database lookup on every request and can carry game-specific claims (current game ID, player position, token metadata).
import jwt from 'jsonwebtoken';
import { createHmac, randomBytes } from 'crypto';
interface LudoPlayerClaims {
sub: string; // user ID
playerId: string; // player position in current game (p1, p2, p3, p4)
gameId: string | null; // null if not in a game
color: 'green' | 'yellow' | 'red' | 'blue' | null;
role: 'player' | 'spectator' | 'admin';
iat: number;
exp: number;
jti: string; // unique token ID — for revocation
}
const JWT_SECRET = process.env.JWT_SECRET!;
const ACCESS_TOKEN_TTL = '2h';
const GAME_TOKEN_TTL = '1h';
// --- Access Token (general auth) ---
function signAccessToken(userId: string, role: string): string {
return jwt.sign(
{
sub: userId,
role,
type: 'access',
jti: randomBytes(16).toString('hex'),
},
JWT_SECRET,
{ algorithm: 'HS256', expiresIn: ACCESS_TOKEN_TTL }
);
}
// --- Game Session Token (short-lived, game-specific) ---
// Issued when player joins a game. Contains game-specific claims.
// Allows the game server to validate moves WITHOUT a DB lookup for every request.
// Embeds the game ID — prevents replaying moves from old games.
function signGameToken(userId: string, gameId: string, playerId: string, color: string): string {
return jwt.sign(
{
sub: userId,
gameId,
playerId,
color,
role: 'player',
type: 'game',
jti: randomBytes(16).toString('hex'),
},
JWT_SECRET,
{ algorithm: 'HS256', expiresIn: GAME_TOKEN_TTL }
);
}
// --- Token Verification Middleware ---
async function authMiddleware(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or malformed Authorization header' });
}
const token = authHeader.slice(7);
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as LudoPlayerClaims;
// Check token revocation (if using a blacklist for logout)
const isRevoked = await redis.sismember('revoked_tokens', decoded.jti);
if (isRevoked) {
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
// For game endpoints, verify the token's gameId matches the request
if (req.gameId && decoded.gameId !== req.gameId) {
return res.status(403).json({
error: 'Token not valid for this game',
code: 'GAME_MISMATCH',
});
}
req.user = decoded;
req.gameId = decoded.gameId;
req.playerId = decoded.playerId;
next();
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
if (err instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ error: 'Invalid token', code: 'INVALID_TOKEN' });
}
throw err;
}
}
// --- Refresh Token Rotation ---
// Access tokens are short-lived (2h). Refresh tokens (7d, stored in httpOnly cookie)
// are used to obtain new access tokens without re-login.
// Refresh tokens are stored in Redis with a reference to the user ID and device fingerprint.
// If a refresh token is used from a different device than it was issued to, invalidate
// all tokens for that user (account compromise detection).
async function handleRefresh(refreshToken: string, deviceFingerprint: string) {
const stored = await redis.hgetall(`refresh:${refreshToken}`);
if (!stored.userId || stored.fingerprint !== deviceFingerprint) {
// Possible account compromise — revoke all tokens for this user
await redis.del(`tokens:user:${stored.userId}`);
throw new SecurityError('Token reuse detected — all sessions revoked');
}
const accessToken = signAccessToken(stored.userId, 'player');
const newRefreshToken = randomBytes(32).toString('hex');
// Rotate: invalidate old refresh token, store new one
await redis.del(`refresh:${refreshToken}`);
await redis.hset(`refresh:${newRefreshToken}`, {
userId: stored.userId,
fingerprint: deviceFingerprint,
issuedAt: Date.now().toString(),
});
await redis.expire(`refresh:${newRefreshToken}`, 7 * 24 * 60 * 60);
return { accessToken, refreshToken: newRefreshToken };
}
Input validation in a Ludo API goes beyond preventing injection attacks — it enforces type safety on every game action. A client sending {"tokenId": "hello"} or {"diceValue": 999} should be immediately rejected with a clear error. Validation must happen at the API boundary, before any game logic runs.
import { z } from 'zod';
// Strict schema validation — every field has explicit type, range, and format constraints
// Using Zod for runtime validation that aligns with TypeScript compile-time types
const PlayerIdSchema = z.string().regex(/^p[1-4]$/, 'Player ID must be p1, p2, p3, or p4');
const TokenIdSchema = z.number().int().min(0).max(3, 'Token ID must be 0-3');
const GameIdSchema = z.string().uuid('Invalid game ID format');
const VersionSchema = z.number().int().min(0, 'Version must be non-negative integer');
const UuidSchema = z.string().uuid();
const IntentSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('ROLL'),
}),
z.object({
type: z.literal('MOVE_TOKEN'),
tokenId: TokenIdSchema,
}),
z.object({
type: z.literal('FORFEIT'),
}),
]);
// Complete game action request schema
const GameActionSchema = z.object({
gameId: GameIdSchema,
version: VersionSchema,
playerId: PlayerIdSchema,
intent: IntentSchema,
// Client metadata for cheat analysis — not used for game logic
// but helps correlate suspicious behavior with client-side data
clientTimestamp: z.number().int().positive().optional(),
clientVersion: z.string().max(20).optional(),
});
// Middleware that validates AND parses in one step
function validateGameAction(req: Request, res: Response, next: NextFunction) {
const result = GameActionSchema.safeParse(req.body);
if (!result.success) {
const errors = result.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
received: i.code === 'invalid_type' ? `received ${i.received}` : undefined,
}));
// Log malformed requests — high volume of these may indicate an exploit toolkit
if (req.ip) {
redis.lpush('malformed_requests', JSON.stringify({
ip: req.ip,
path: req.path,
errors,
body: req.body,
timestamp: Date.now(),
}));
redis.ltrim('malformed_requests', 0, 9999); // cap at 10k entries
}
return res.status(400).json({
error: 'Invalid request body',
code: 'VALIDATION_ERROR',
details: errors,
});
}
// Replace req.body with parsed (and defaulted) version
req.body = result.data;
next();
}
// Additional validation layer: check if game action is temporally reasonable
function validateActionTiming(req: Request, res: Response, next: NextFunction) {
const { clientTimestamp } = req.body;
// Client timestamp should be within 5 seconds of server time
// Large deltas suggest replay attacks (old requests being replayed)
if (clientTimestamp) {
const drift = Math.abs(Date.now() - clientTimestamp);
if (drift > 5000) {
return res.status(400).json({
error: 'Request timestamp drift too large',
code: 'TIMESTAMP_DRIFT',
driftMs: drift,
});
}
// Client timestamp in the future — possible clock manipulation
if (clientTimestamp > Date.now() + 1000) {
return res.status(400).json({
error: 'Request timestamp is in the future',
code: 'FUTURE_TIMESTAMP',
});
}
}
next();
}
Server-authoritative architecture stops cheaters from manipulating outcomes. But determined attackers still probe the boundaries — sending borderline invalid moves, unusual move sequences, or timing patterns that suggest bot usage. A robust cheat detection system sits alongside validation, passively analyzing the game history for anomalies.
The server maintains a canonical dice history. Every move references a dice roll. If a client claims to have moved multiple tokens in one turn without a 6 (or with fewer 6s than moves), the move is impossible under Ludo rules.
interface SequenceCheckResult {
valid: boolean;
explanation: string;
severity: 'none' | 'warning' | 'critical';
}
// Verify that the dice rolls in history support the claimed move sequence
function detectImpossibleSequences(game: GameState): SequenceCheckResult[] {
const results: SequenceCheckResult[] = [];
// Group history by turn number
const turnsByPlayer = new Map<PlayerId, TurnRecord[]>();
for (const record of game.turnHistory) {
if (record.type === 'ROLL' || record.type === 'MOVE') {
const turns = turnsByPlayer.get(record.playerId) || [];
turns.push(record);
turnsByPlayer.set(record.playerId, turns);
}
}
for (const [playerId, records] of turnsByPlayer) {
let consecutiveSixes = 0;
let movesMade = 0;
for (const record of records) {
if (record.type === 'ROLL') {
if (record.value === 6) {
consecutiveSixes++;
} else {
consecutiveSixes = 0;
}
} else if (record.type === 'MOVE') {
movesMade++;
}
}
// Rule: if 3 consecutive 6s are rolled, the turn passes immediately
if (consecutiveSixes >= 3) {
results.push({
valid: false,
explanation: `${playerId} rolled 3 consecutive 6s without a non-6 break — turn should have passed`,
severity: 'critical',
});
}
}
return results;
}
// Real-time anomaly: detect if a move references a dice roll the player never made
function detectStaleDiceReference(game: GameState, move: MoveRecord): boolean {
const relevantRolls = game.turnHistory.filter(
(r) => r.type === 'ROLL' && r.playerId === move.playerId && r.timestamp < move.timestamp
);
// Find the most recent roll by this player before this move
const lastRoll = relevantRolls[relevantRolls.length - 1];
// If the last roll was more than 30 seconds ago, the move is stale
// (Ludo turns have deadlines — a move after the deadline referencing old dice is invalid)
if (lastRoll && Date.now() - lastRoll.timestamp > 30_000) {
return true; // stale
}
return false;
}
Humans have a minimum reaction time of ~180-200ms between receiving information and executing an action. Bots can respond in 20-50ms. Analyze inter-move intervals across a session.
interface BotAnalysis {
playerId: PlayerId;
gameId: GameId;
totalMoves: number;
avgInterMoveMs: number;
minInterMoveMs: number;
botProbability: number; // 0-1 score
recommendation: 'none' | 'flag' | 'review' | 'ban';
}
function analyzeMoveTiming(game: GameState, playerId: PlayerId): BotAnalysis {
const moveRecords = game.turnHistory
.filter((r) => r.type === 'MOVE' && r.playerId === playerId)
.sort((a, b) => a.timestamp - b.timestamp);
if (moveRecords.length < 3) {
return { playerId, gameId: game.gameId, totalMoves: 0, avgInterMoveMs: 0, minInterMoveMs: 0, botProbability: 0, recommendation: 'none' };
}
const intervals: number[] = [];
for (let i = 1; i < moveRecords.length; i++) {
intervals.push(moveRecords[i].timestamp - moveRecords[i - 1].timestamp);
}
const avgInterMoveMs = intervals.reduce((a, b) => a + b, 0) / intervals.length;
const minInterMoveMs = Math.min(...intervals);
const fastMoves = intervals.filter((ms) => ms < 250).length;
// Bot probability: based on average inter-move time and count of sub-250ms moves
// Human minimum ≈ 200ms (visual reaction) + 300ms (motor + decision) ≈ 500ms
// Anything under 300ms is suspicious. Under 150ms is almost certainly bot.
const speedScore = Math.max(0, 1 - (avgInterMoveMs - 200) / 500);
const consistencyScore = fastMoves / intervals.length;
const botProbability = Math.min(1, speedScore * 0.6 + consistencyScore * 0.4);
let recommendation: BotAnalysis['recommendation'] = 'none';
if (botProbability > 0.85) recommendation = 'ban';
else if (botProbability > 0.65) recommendation = 'review';
else if (botProbability > 0.45) recommendation = 'flag';
return {
playerId,
gameId: game.gameId,
totalMoves: moveRecords.length,
avgInterMoveMs: Math.round(avgInterMoveMs),
minInterMoveMs,
botProbability: Math.round(botProbability * 100) / 100,
recommendation,
};
}
// Periodic analysis: run after each game finishes
async function runPostGameAnalysis(game: GameState): Promise<BotAnalysis[]> {
const results: BotAnalysis[] = [];
const playerIds = Object.keys(game.players);
for (const playerId of playerIds) {
const analysis = analyzeMoveTiming(game, playerId);
results.push(analysis);
if (analysis.recommendation !== 'none') {
// Store for admin review queue
await redis.lpush('cheat_review_queue', JSON.stringify({
...analysis,
gameId: game.gameId,
analyzedAt: Date.now(),
}));
// Auto-flag for manual review
await redis.hset(`player_flags:${playerId}`, game.gameId, JSON.stringify({
type: 'bot_probability',
probability: analysis.botProbability,
recommendation: analysis.recommendation,
avgMs: analysis.avgInterMoveMs,
}));
// If probability is very high, temporarily suspend the account
if (analysis.recommendation === 'ban') {
await suspendAccount(playerId, '4h', `Bot probability: ${analysis.botProbability} (${analysis.totalMoves} moves, avg ${analysis.avgInterMoveMs}ms)`);
}
}
}
return results;
}
Legitimate dice rolls follow a uniform distribution over 1-6. A modified client — or a player selectively re-rolling in-game until they get favorable values — will show statistical anomalies. Chi-square tests on roll distributions catch modified clients.
// Chi-square goodness-of-fit test for dice fairness
// Expected: uniform distribution over 1-6
// If a player's rolls deviate significantly, their client may be manipulating dice
function chiSquareDiceTest(rolls: number[]): {
chiSquare: number;
pValue: number;
isSuspicious: boolean;
} {
const expectedFrequency = rolls.length / 6;
const observed: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 };
for (const roll of rolls) {
if (roll >= 1 && roll <= 6) {
observed[roll]++;
}
}
// Chi-square statistic
let chiSquare = 0;
for (let face = 1; face <= 6; face++) {
const observedFreq = observed[face];
chiSquare += Math.pow(observedFreq - expectedFrequency, 2) / expectedFrequency;
}
// Degrees of freedom = 6 - 1 = 5
// p-value from chi-square distribution with df=5
const pValue = 1 - chiSquareCDF(chiSquare, 5);
// p < 0.05 indicates statistically significant deviation
// With 30+ rolls, this test has meaningful statistical power
const isSuspicious = pValue < 0.05 && rolls.length >= 30;
return { chiSquare: Math.round(chiSquare * 1000) / 1000, pValue: Math.round(pValue * 1000) / 1000, isSuspicious };
}
// Cumulative distribution function for chi-square with k degrees of freedom
function chiSquareCDF(x: number, k: number): number {
// Lower incomplete gamma function approximation
// For production, use a library like jstat or statjs
if (x <= 0) return 0;
return gammaCDF(x / 2, k / 2);
}
function gammaCDF(x: number, a: number): number {
// Using the regularized lower incomplete gamma function
// For production use: import { gammainc } from 'special-functions';
return 1; // Placeholder — use a proper implementation
}
Every legitimate move, dice roll, and game event must be recorded in an immutable, ordered event log. This log serves three purposes: (1) reconstruct the exact game state at any point for debugging, (2) provide evidence in dispute resolution, and (3) feed the cheat detection pipelines.
type GameEvent =
| { type: 'GAME_CREATED'; gameId: string; timestamp: number; players: PlayerId[] }
| { type: 'PLAYER_JOINED'; playerId: PlayerId; timestamp: number }
| { type: 'DICE_ROLLED'; playerId: PlayerId; value: number; seed: string; timestamp: number }
| { type: 'TOKEN_MOVED'; playerId: PlayerId; tokenId: number; from: Position; to: Position; timestamp: number }
| { type: 'TOKEN_CAPTURED'; playerId: PlayerId; capturedPlayerId: PlayerId; capturedTokenId: number; timestamp: number }
| { type: 'TOKEN_FINISHED'; playerId: PlayerId; tokenId: number; timestamp: number }
| { type: 'TURN_PASSED'; playerId: PlayerId; reason: 'roll' | 'no_valid_move' | 'timeout'; timestamp: number }
| { type: 'PLAYER_DISCONNECTED'; playerId: PlayerId; timestamp: number }
| { type: 'GAME_FINISHED'; winnerId: PlayerId; timestamp: number; finalState: string };
class ReplayRecorder {
private events: GameEvent[] = [];
record(event: GameEvent): void {
this.events.push({
...event,
// Internally, we also add a sequence number for ordering
// @ts-ignore — extends the event type internally
_seq: this.events.length,
});
}
// Reconstruct game state at any point in time
reconstructState(targetSeq: number): GameState {
let state = this.createInitialState();
for (const event of this.events) {
// @ts-ignore
if (event._seq > targetSeq) break;
state = this.applyEvent(state, event);
}
return state;
}
// Export as a self-contained JSON that can replay the entire game
export(): string {
return JSON.stringify({
version: 1,
exportedAt: Date.now(),
events: this.events,
});
}
// Validate replay integrity: run through all events and verify each
// move was legal according to the rules at that point in time
async validateIntegrity(): Promise<{ valid: boolean; violations: IntegrityViolation[] }> {
const violations: IntegrityViolation[] = [];
let state = this.createInitialState();
for (const event of this.events) {
// @ts-ignore
if (event.type === 'TOKEN_MOVED') {
const context: MoveContext = {
game: state,
playerId: event.playerId,
tokenId: event.tokenId,
diceValue: this.findDiceForMove(state, event),
gameVersion: state.version,
};
const validation = await validateMove(context);
if (!validation.valid) {
violations.push({
seq: event._seq,
event,
reason: validation.error!,
threatLevel: validation.threatLevel,
});
}
}
// @ts-ignore
state = this.applyEvent(state, event);
}
return { valid: violations.length === 0, violations };
}
private findDiceForMove(state: GameState, moveEvent: any): number {
// Find the dice roll that authorized this move
const roll = [...state.turnHistory]
.reverse()
.find((r) => r.type === 'ROLL' && r.playerId === moveEvent.playerId && r.timestamp < moveEvent.timestamp);
return roll?.value ?? 0;
}
}
// Dispute resolution endpoint
app.get('/api/games/:gameId/replay', authMiddleware, async (req, res) => {
const game = await getGame(req.params.gameId);
if (!game) return res.status(404).json({ error: 'Game not found' });
// Only participants and admins can access replay
const isParticipant = Object.keys(game.players).includes(req.playerId);
const isAdmin = req.user.role === 'admin';
if (!isParticipant && !isAdmin) {
return res.status(403).json({ error: 'Not authorized to view this replay' });
}
const recorder = new ReplayRecorder();
// Load events from storage and replay
const events = await db.query('SELECT event FROM game_events WHERE game_id = $1 ORDER BY seq', [req.params.gameId]);
for (const row of events.rows) {
recorder.record(JSON.parse(row.event));
}
res.json({
gameId: req.params.gameId,
replay: recorder.export(),
integrity: await recorder.validateIntegrity(),
});
});
Yes, absolutely. WebSocket connections use the same JWT authentication mechanism as REST endpoints. The difference is operational: REST APIs validate on each HTTP request cycle, while WebSocket messages are multiplexed over a persistent connection. For WebSockets, you should validate the JWT on connection establishment and embed the game context (gameId, playerId) in the WebSocket session. Every subsequent message should be validated for: (1) consistency with the session's embedded game context, (2) move validity against the server's canonical state, and (3) timing (no message should arrive within milliseconds of the previous, which would indicate a bot script). Rate limiting for WebSocket messages should be measured per-message-type rather than per-request — for example, no more than 2 ROLL intents per 5 seconds per connection. The server-authoritative principle remains non-negotiable: the server generates dice, not the client, even over WebSocket.
Intentional disconnection abuse (rage-quitting) is a fairness issue, not a technical cheat, but it undermines the experience for remaining players. Implement a grace period system: when a player disconnects, start a 60-second countdown. During this window, the player can reconnect and continue. After the window expires: (1) if the game is still in progress and the player hasn't moved, automatically forfeit their turn (or pass it), (2) if the player fails to reconnect within the forfeit window (typically 3 turn cycles), the game ends and the remaining player wins. Store the disconnect reason (voluntary close vs. network timeout vs. server-side disconnect) in the event log. Players with a pattern of voluntary disconnects before losing positions should receive a temporary matchmaking cooldown. For real-money games, disconnects mid-game should trigger a review before distributing rewards.
Account sharing is difficult to prevent definitively without invasive measures, but you can detect and deter it. The key signals to correlate are: (1) device fingerprinting — multiple accounts accessed from identical device IDs, browser fingerprints, or IP addresses within overlapping time windows, (2) behavioral fingerprinting — accounts that share identical move timing patterns (inter-move intervals within 5% of each other across 50+ moves), (3) account linkage — accounts registered with the same email prefix pattern, phone number, or payment method. Flag accounts for review when these signals exceed thresholds. For serious games (ranked, real-money), enforce single-device-per-account with device binding on first login. Accept that determined account sharers can use VPNs and different devices — the goal is raising the cost of abuse, not achieving perfect prevention.
For casual, non-monetized games: server-authoritative dice + move validation + JWT auth + basic rate limiting (100 req/min per IP). That's the minimum to prevent trivial cheats. You can skip the dice chain auditability and statistical bot detection — the effort isn't worth the return when stakes are low.
For ranked matchmaking: add the bot detection timing analysis, per-user rate limits on game actions, version-checking for client builds, and account reputation scoring.
For real-money Ludo games: implement the full stack described in this guide — dice chain with cryptographic auditability, replay system for dispute evidence, multi-factor behavioral analysis, device fingerprinting, and a dedicated anti-cheat review pipeline. Real-money stakes attract sophisticated attackers who will reverse-engineer your API. You need every layer.
The LudoKing REST API exposes endpoints for game creation, matchmaking, and move submission. The security architecture described here is implemented as middleware and service layers that wrap these endpoints. The Node.js SDK handles JWT token management, request signing, and the client-side half of the WebSocket handshake. The game architecture document covers how the server maintains canonical state, distributes updates via WebSocket, and persists events to the replay log. All three layers — API (this guide), SDK (client integration), and architecture (server internals) — are designed to work together as a cohesive security perimeter.
Follow a structured incident response process: (1) Preserve evidence — dump the entire event log for all games involving the suspicious account to immutable cold storage before any account action, (2) Scope the damage — determine how many games were affected, what rewards were claimed, and the financial impact, (3) Verify the exploit — reproduce the attack in a staging environment to confirm it works and to understand the exact vulnerability, (4) Patch the vulnerability — implement the fix in production with an emergency deploy if necessary, (5) Ban the account — issue a permanent ban with clear evidence cited in the ban notice. Do not publicly disclose the vulnerability details until patches are deployed. For real-money games, involve legal counsel and consider reporting to relevant gaming regulatory authorities depending on your jurisdiction.
Get expert help building a hardened Ludo game backend with anti-cheat protection, JWT authentication, and real-time cheat detection tailored to your platform.
💬 Talk to an Expert on WhatsApp