How Ludo Works: Complete Step-by-Step Rules

Ludo is a classic race board game for 2–4 players where each player controls four pieces that must complete a full circuit around the board before reaching the center. The player who finishes all four pieces first wins. Understanding the complete rule set is essential before writing a single line of game logic — the rules drive every data structure and algorithm in your implementation.

Game Setup

Each of the four players (Red, Green, Yellow, Blue) occupies one corner quadrant of the board. Every player has exactly four pieces that start in their home base within that quadrant. Players take turns clockwise, rolling a single six-sided die to determine how far to move. The board itself consists of three distinct regions: an outer track of 52 squares shared by all players, four private home columns of 5 squares each, and a central finish zone.

Dice Rolling Mechanics

A single standard six-sided die determines movement. Rolling a 6 grants two privileges: a piece may leave the home base (normally forbidden without a 6), and the player earns an extra turn after completing the current move. Three consecutive 6s in a row, however, forfeit the turn immediately — the player's move ends and control passes to the next player. This rule creates meaningful risk-reward decisions and prevents games from dragging on indefinitely.

Capture Mechanics

When a piece lands on an opponent's piece occupying the same square, that opponent's piece is sent back to their home base and must restart from scratch. However, two categories of squares grant immunity: start squares (where each color first enters the outer track) and star squares (positioned halfway between each start square). Pieces on these safe squares cannot be captured. Within the private home column, pieces are also immune from capture — only the owning player can enter that column.

Winning the Game

A piece must enter the home column after completing the outer circuit and then travel the exact number of squares remaining to reach the center. Overshooting is strictly prohibited — if the dice value would move a piece past position 57 (the center), that move is illegal and the piece stays in place. The game ends when one player has all four of their pieces in the finish zone. At that point, the game records the winner and all remaining players' positions are frozen.

The Board Coordinate System: Full 52-Square Mapping

The Ludo board uses a 15×15 conceptual grid where each cell is addressed as (row, col) with (0,0) at the top-left. Rather than managing individual pixel coordinates directly, the board is abstracted into a linear track of positions 0–57. Positions 0–51 represent the 52 outer track squares, positions 52–56 represent the five-step home column, and position 57 is the finish center. Each player has a different starting offset into this shared track, which is why the same track index maps to different grid cells depending on which player's perspective you take.

Board Layout (ASCII Diagram)

     0   1   2   3   4   5   6   7   8   9  10  11  12  13  14
   +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
 0 | R1| R1| R1| R1| R1|   |   | G1| G1| G1| G1| G1|   |   |   |
   +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
 1 | R1| R1| R1| R1| R1|   |   | G1| G1| G1| G1| G1|   |   |   |
   +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
 2 | R1| R1| R1| R1| R1|   |   | G1| G1| G1| G1| G1|   |   |   |
   +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
 3 | R1| R1| R1| R1| R1|   |   | G1| G1| G1| G1| G1|   |   |   |
   +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
 4 | R1| R1| R1| R1| R1|   |   | G1| G1| G1| G1| G1|   |   |   |
   +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
 5 |   |   |   |   |   |   |   |   |   |   |   |   |   |   | G5|
   +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
 6 | R5|   |   |   |   |   | ★ | R6| R7| R8| R9| R10| R11| ★ |G6|
   +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
 7 | ★ |   |   |   |   | Y5| Y6| Y7| Y8| Y9| Y10|   |   |   |G7|
   +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
 8 | B6|   |   |   |   | B5| ★ | B7| B8| ★ | B9| B10| B11| ★ |G8|
   +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
 9 |   |   |   |   |   |   |   |   |   |   |   |   |   |   | G9|
   +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
10 |   |   |   |   |   |   |   |   |   |   |   |   |   |   |G10|
   +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
11 |   |   |   |   |   |   |   |   |   |   |   |   |   |   |G11|
   +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
12 |   |   |   |   |   |   |   |   |   |   |   |   |   |   |G12|
   +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
13 |   |   |   |   |   |   |   |   |   |   |   |   |   |   |G13|
   +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
14 |   |   |   |   |   |   | B1| B1| B1| B1| B1|   |   |   |   |
   +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+

Legend: ★ = Safe/Star square   R/G/Y/B = Home quadrants
        R5–R11 etc = Outer track cells with RED's track positions

Full Board Coordinate Mapping Table

The following table maps every outer track position (0–51) to its grid coordinates, along with the corresponding position for each player. Because each player enters the track from a different square, the same track index maps to different coordinates depending on the player's perspective.

Track Pos Grid (row, col) Square Type Red Pos Green Pos Yellow Pos Blue Pos
0(6, 1)Start (Red)0392613
1(6, 2)Track1402714
2(6, 3)Track2412815
3(6, 4)Track3422916
4(6, 5)Track4433017
5(6, 6)Track5443118
6(6, 7)Track6453219
7(6, 8)Track7463320
8(6, 9)Star / Safe8473421
9(6, 10)Track9483522
10(6, 11)Track10493623
11(6, 12)Track11503724
12(6, 13)Track12513825
13(7, 14)Start (Green)1303926
14(6, 14)Track1414027
15(5, 14)Track1524128
16(4, 14)Track1634229
17(3, 14)Track1744330
18(2, 14)Track1854431
19(1, 14)Track1964532
20(0, 14)Track2074633
21(0, 13)Star / Safe2184734
22(0, 12)Track2294835
23(0, 11)Track23104936
24(0, 10)Track24115037
25(0, 9)Track25125138
26(0, 8)Start (Yellow)2613039
27(0, 7)Track2714140
28(0, 6)Track2815241
29(0, 5)Track2916342
30(0, 4)Track3017443
31(0, 3)Track3118544
32(0, 2)Track3219645
33(0, 1)Track3320746
34(0, 0)Star / Safe3421847
35(1, 0)Track3522948
36(2, 0)Track36231049
37(3, 0)Track37241150
38(4, 0)Track38251251
39(5, 0)Start (Blue)3926130
40(6, 0)Track4027141
41(7, 0)Track4128152
42(8, 0)Track4229163
43(8, 1)Track4330174
44(8, 2)Track4431185
45(8, 3)Track4532196
46(8, 4)Track4633207
47(8, 5)Star / Safe4734218
48(8, 6)Track4835229
49(8, 7)Track49362310
50(8, 8)Track50372411
51(8, 9)Track51382512

The home column coordinates for each player are defined separately from the outer track, since each player's home column leads directly toward the center from their respective entry point. For Red, the home column is (6,0)(7,0)(8,0)(8,1)(8,2)(8,3). For Green: (6,14)(5,14)(4,14)(3,14)(2,14)(1,14). Yellow's column goes (0,8)(0,7)(0,6)(0,5)(0,4)(0,3). Blue's column follows (8,0)(9,0)(10,0)(11,0)(12,0)(13,0).

Movement Decision Tree: Branching Rules for Each Color

The movement decision tree encodes the complete branching logic that determines which legal moves are available to a player given a specific dice value. Every branch point corresponds to a rule that must be checked in order, with the tree returning a ranked list of preferred moves at each leaf.

DECISION TREE: getBestMove(playerId, diceValue)
├── Is any piece finished?
│   └── Remove from movable candidates
├── Does player have a piece in base (pos == -1)?
│   ├── diceValue == 6?
│   │   └── → Consider "leave base" move (HIGH priority: escape first)
│   └── diceValue != 6?
│       └── → Cannot leave base (exclude base pieces from candidates)
├── For each candidate piece (pos >= 0, not finished):
│   ├── Can piece reach home column entry?
│   │   ├── pos + diceValue == HOME_ENTRY[playerId] + 52?
│   │   │   └── → Flag as "home column entry" candidate
│   │   └── pos + diceValue > HOME_ENTRY[playerId] + 52?
│   │       └── → Must check home column distance
│   ├── Would move exceed WIN_POSITION (57)?
│   │   └── → Move invalid (exclude this piece)
│   ├── Would move land on own piece?
│   │   └── → Move invalid (self-collision)
│   ├── Would move land on opponent's piece on safe square?
│   │   └── → Move invalid (safe square protection)
│   └── Would move land on opponent's piece (not safe)?
│       └── → Capture candidate (MEDIUM-HIGH priority)
├── RANKING RULES (descending priority):
│   ├── 1. Capture opponent's piece (eliminate competition)
│   ├── 2. Enter home column (accelerates finishing)
│   ├── 3. Leave base on a 6 (only way to start)
│   ├── 4. Move onto a safe/star square (protection)
│   ├── 5. Move closest to finish (progress optimization)
│   └── 6. Any valid move (fallback)
└── Return ranked move list for player selection

JavaScript Implementation

JavaScript — Complete movement decision tree
const PLAYER_COLORS = ['RED', 'GREEN', 'YELLOW', 'BLUE'];

// Home entry index into the absolute OUTER_TRACK for each player
const HOME_ENTRY = { RED: 0, GREEN: 13, YELLOW: 26, BLUE: 39 };
const WIN_POSITION = 57;
const OUTER_TRACK_LEN = 52;

// Star/safe squares: indices 8, 21, 34, 47 in the absolute track
const SAFE_SQUARES = new Set([8, 21, 34, 47]);

function toAbsoluteTrack(relPos, playerId) {
    // Convert player's relative track position (0-57) to absolute (0-51)
    return (relPos + HOME_ENTRY[playerId]) % OUTER_TRACK_LEN;
}

function getMoveCandidates(player, diceValue) {
    const candidates = [];

    for (const piece of player.pieces) {
        if (piece.finished) continue;

        // Rule: piece in base needs exactly 6 to exit
        if (piece.trackPos === -1) {
            if (diceValue === 6) {
                candidates.push({ piece, moveType: 'LEAVE_BASE', targetPos: 0, priority: 3 });
            }
            continue;
        }

        // Rule: cannot overshoot the finish
        const newPos = piece.trackPos + diceValue;
        if (newPos > WIN_POSITION) continue;

        // Rule: self-collision check — cannot land on own piece
        const selfBlocked = player.pieces.some(
            p => p !== piece && !p.finished && p.trackPos === newPos
        );
        if (selfBlocked) continue;

        // Classify the move
        let moveType = 'ADVANCE';
        let priority = 6;

        // Check for capture opportunity
        const absTarget = toAbsoluteTrack(newPos, player.id);
        const opponentOnSquare = findOpponentAtAbsolute(absTarget, player.id);
        if (opponentOnSquare && !isSafeSquare(absTarget)) {
            moveType = 'CAPTURE';
            priority = 1;
        }

        // Check for home column entry
        if (piece.trackPos < OUTER_TRACK_LEN && newPos >= OUTER_TRACK_LEN) {
            moveType = 'ENTER_HOME';
            priority = Math.max(priority, 2);
        }

        // Check for safe square landing
        if (isSafeSquare(absTarget) && moveType === 'ADVANCE') {
            moveType = 'SAFE_SQUARE';
            priority = 4;
        }

        candidates.push({ piece, moveType, targetPos: newPos, priority });
    }

    // Sort by priority (lowest number = highest priority)
    candidates.sort((a, b) => a.priority - b.priority);
    return candidates;
}

function findOpponentAtAbsolute(absTrackPos, ownPlayerId) {
    // Returns opponent player ID if one occupies the absolute track position
    for (const player of gameState.players) {
        if (player.id === ownPlayerId) continue;
        for (const piece of player.pieces) {
            if (piece.finished || piece.trackPos < 0) continue;
            if (toAbsoluteTrack(piece.trackPos, player.id) === absTrackPos) {
                return player.id;
            }
        }
    }
    return null;
}

function isSafeSquare(absTrackPos) {
    return SAFE_SQUARES.has(absTrackPos);
}

Dice Probability Distribution

Understanding dice probability distribution is critical for game balance and AI decision-making. A single die has a uniform distribution — each face (1 through 6) has a 1/6 probability of appearing. However, meaningful strategic information emerges when you consider the distribution of outcomes for multiple rolls and the conditional probabilities based on game state.

Single Roll Probability Table

Dice Value Probability Odds Cumulative Strategic Note
116.67%1 in 616.67%Move one square — limited utility
216.67%1 in 633.33%Useful for near-finish pieces
316.67%1 in 650.00%Balanced movement, common choice
416.67%1 in 666.67%Large jump, good for long-distance
516.67%1 in 683.33%Significant progress, near home column
616.67%1 in 6100.00%Leave base OR extra turn — highest value

Consecutive Roll Probabilities

JavaScript — Dice probability calculator
const SINGLE_PROB = 1 / 6; // ~16.67% per face

// Probability of getting exactly N consecutive 6s
function probConsecutiveSixes(n) {
    return Math.pow(SINGLE_PROB, n);
}

// P(exactly one 6 in exactly one roll) = 1/6 ≈ 16.67%
// P(exactly one 6 in N rolls) = N * (1/6) * (5/6)^(N-1)
function probAtLeastOneSix(nRolls) {
    return 1 - Math.pow(5 / 6, nRolls);
}

// Three consecutive 6s: P = (1/6)^3 = 0.46% — rare but consequential
// After 2 consecutive 6s, P(third is also 6) = 16.67%
// After 2 consecutive 6s, P(at least one non-6 in next N rolls) → approaches 1

// Build a probability-weighted move score for AI evaluation
function expectedProgress(piece, diceDist = [1,1,1,1,1,1]) {
    // diceDist: weighted die (default = fair die)
    let total = 0;
    for (const [value, weight] of diceDist.entries()) {
        if (isValidMove(piece, value)) {
            total += weight * value;
        }
    }
    return total / diceDist.reduce((a, b) => a + b, 0);
}

// Monte Carlo: estimate turns-to-finish for a piece
async function monteCarloTurnsToFinish(startPos, trials = 100000) {
    let totalTurns = 0;
    let consecSix = 0;
    for (let t = 0; t < trials; t++) {
        let pos = startPos;
        let turns = 0;
        let consec = 0;
        while (pos < WIN_POSITION && turns < 500) {
            const roll = Math.floor(Math.random() * 6) + 1;
            consec = (roll === 6) ? consec + 1 : 0;
            if (consec >= 3) { consec = 0; continue; }
            if (pos === -1 && roll !== 6) { turns++; continue; }
            if (pos + roll <= WIN_POSITION) {
                pos += roll;
            }
            turns++;
        }
        totalTurns += turns;
    }
    return totalTurns / trials;
}

Animation Timing System

Smooth animation elevates a Ludo game from a functional prototype to a polished product. The animation system must handle piece movement along the track (which may cross multiple squares in a single dice roll), the dice rolling visual effect, capture animations (where the captured piece retreats to base), and home-entry celebrations. Each animation type has a different duration profile, and the system must coordinate them without race conditions.

JavaScript — Animation timing coordinator
const ANIM_CONFIG = {
    SQUARE_STEP_MS:  120,   // ms per square for piece movement
    DICE_ROLL_MS:    800,   // total dice roll animation duration
    CAPTURE_MS:      600,   // capture retreat animation
    HOME_ENTRY_MS:   400,   // celebration pulse on home entry
    SETTLE_MS:       200,   // pause between animations
    SCALE_BOUNCE:    1.15,  // piece scale during bounce
};

class LudoAnimationController {
    constructor(canvasCtx) {
        this.ctx = canvasCtx;
        this.queue = [];
        this.running = false;
        this.currentAnim = null;
    }

    enqueue(anim) {
        this.queue.push(anim);
        if (!this.running) this.processQueue();
    }

    processQueue() {
        if (this.queue.length === 0) {
            this.running = false;
            this.emit('allAnimationsComplete');
            return;
        }
        this.running = true;
        this.currentAnim = this.queue.shift();
        this.runAnimation(this.currentAnim)
            .then(() => {
                this.emit(this.currentAnim.type + ':complete', this.currentAnim);
                return this.wait(ANIM_CONFIG.SETTLE_MS);
            })
            .then(() => this.processQueue());
    }

    runAnimation(anim) {
        switch (anim.type) {
            case 'ROLL':
                return this.animateDiceRoll(anim);
            case 'MOVE':
                return this.animatePieceMove(anim);
            case 'CAPTURE':
                return this.animateCapture(anim);
            case 'HOME_ENTRY':
                return this.animateHomeEntry(anim);
        }
    }

    animateDiceRoll(anim) {
        return new Promise(resolve => {
            const duration = ANIM_CONFIG.DICE_ROLL_MS;
            const fps = 30;
            const frames = (duration / 1000) * fps;
            let frame = 0;
            const interval = setInterval(() => {
                const value = Math.floor(Math.random() * 6) + 1;
                this.renderDiceFace(value);
                if (++frame >= frames) {
                    clearInterval(interval);
                    this.renderDiceFace(anim.result);
                    resolve();
                }
            }, 1000 / fps);
        });
    }

    animatePieceMove(anim) {
        return new Promise(resolve => {
            const steps = Math.abs(anim.toPos - anim.fromPos);
            const stepDuration = ANIM_CONFIG.SQUARE_STEP_MS;
            const totalDuration = steps * stepDuration;
            const startTime = performance.now();
            const fromCoord = getTrackCoordinate(anim.fromPos, anim.playerId);
            const toCoord = getTrackCoordinate(anim.toPos, anim.playerId);

            const animate = (now) => {
                const elapsed = now - startTime;
                const t = Math.min(elapsed / totalDuration, 1);
                const eased = easeInOutQuad(t);
                const currentStep = Math.floor(eased * steps);
                const segmentT = (eased * steps) - currentStep;
                const fromSq = getTrackCoordinate(anim.fromPos + currentStep, anim.playerId);
                const toSq = getTrackCoordinate(anim.fromPos + currentStep + 1, anim.playerId);
                const x = lerp(fromSq.x, toSq.x, segmentT);
                const y = lerp(fromSq.y, toSq.y, segmentT);
                this.renderPiece(anim.pieceId, x, y);
                if (t < 1) requestAnimationFrame(animate);
                else resolve();
            };
            requestAnimationFrame(animate);
        });
    }

    function lerp(a, b, t) { return a + (b - a) * t; }
    function easeInOutQuad(t) { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }

    wait(ms) { return new Promise(r => setTimeout(r, ms)); }
    emit(type, data) { this.dispatchEvent && this.dispatchEvent(new CustomEvent(type, { detail: data })); }
}

Testing the Ludo Game: Comprehensive Test Scenarios

Every Ludo implementation requires systematic testing across the board coordinate system, movement validation, capture logic, win detection, and dice rule enforcement. The following test suite covers the critical paths that, if broken, would produce incorrect game behavior.

JavaScript — Ludo game test suite
function runLudoTestSuite() {
    const results = [];
    const test = (name, fn) => {
        try {
            fn();
            results.push({ name, pass: true });
            console.log('✅', name);
        } catch (e) {
            results.push({ name, pass: false, error: e.message });
            console.error('❌', name, e.message);
        }
    };

    // --- Board Coordinate Tests ---
    test('OUTER_TRACK has exactly 52 cells', () => {
        assertEqual(OUTER_TRACK.length, 52, 'Track length must be 52');
    });

    test('Home entry indices are spaced 13 apart', () => {
        assertEqual(HOME_ENTRY.GREEN - HOME_ENTRY.RED, 13);
        assertEqual(HOME_ENTRY.YELLOW - HOME_ENTRY.GREEN, 13);
        assertEqual(HOME_ENTRY.BLUE - HOME_ENTRY.YELLOW, 13);
        assertEqual((HOME_ENTRY.RED + 52 - HOME_ENTRY.BLUE) % 52, 13);
    });

    test('Safe squares are at indices 8, 21, 34, 47', () => {
        const expected = [8, 21, 34, 47];
        expected.forEach(idx => assertEqual(SAFE_SQUARES.has(idx), true));
    });

    test('Track wraps correctly: Red pos 51 + 1 = Green pos 0', () => {
        const greenEntry = HOME_ENTRY.GREEN;
        const redLast = OUTER_TRACK[51];
        const greenFirst = OUTER_TRACK[greenEntry];
        assertEqual(toAbsoluteTrack(51, 'RED'), 51);
        assertEqual(toAbsoluteTrack(0, 'GREEN'), greenEntry);
    });

    // --- Movement Validation Tests ---
    test('Piece in base can only move with dice=6', () => {
        const piece = { trackPos: -1, finished: false };
        assertEqual(isValidMove(piece, 6), true);
        [1,2,3,4,5].forEach(v =>
            assertEqual(isValidMove(piece, v), false)
        );
    });

    test('Cannot overshoot WIN_POSITION (57)', () => {
        const piece = { trackPos: 55, finished: false };
        assertEqual(isValidMove(piece, 2), true);  // 55+2=57 exact
        assertEqual(isValidMove(piece, 3), false);  // 55+3=58 overshoot
        assertEqual(isValidMove({ trackPos: 56, finished: false }, 2), false);
        assertEqual(isValidMove({ trackPos: 56, finished: false }, 1), true);  // 56+1=57 exact
    });

    test('Finished piece cannot move', () => {
        assertEqual(isValidMove({ trackPos: 57, finished: true }, 6), false);
    });

    test('getMoveCandidates filters correctly for self-collision', () => {
        const player = {
            id: 'RED',
            pieces: [
                { trackPos: 10, finished: false },
                { trackPos: 10, finished: false } // same position — self-collision
            ]
        };
        const cands = getMoveCandidates(player, 3);
        assertEqual(cands.length, 0); // both blocked
    });

    test('Total path for each player is exactly 57 steps', () => {
        ['RED', 'GREEN', 'YELLOW', 'BLUE'].forEach(color => {
            const piece = { trackPos: 0, finished: false };
            let steps = 0;
            while (piece.trackPos < WIN_POSITION && steps < 200) {
                piece.trackPos++;
                steps++;
            }
            assertEqual(steps, 57, `${color} path should be 57 steps`);
        });
    });

    // --- Dice Rule Tests ---
    test(probConsecutiveSixes(3).toFixed(4) === (1/216).toFixed(4)
        ? 'Three consecutive 6s probability is (1/6)^3 = 0.0046'
        : assertFail('Three 6s probability incorrect')
    );

    const passed = results.filter(r => r.pass).length;
    const failed = results.filter(r => !r.pass).length;
    console.log(`\nResults: ${passed} passed, ${failed} failed`);
    return failed === 0;
}

function assertEqual(a, b, msg) {
    if (a !== b) throw new Error(msg || `Expected ${b}, got ${a}`);
}
function assertFail(msg) { throw new Error(msg); }

Frequently Asked Questions

The Ludo board has 52 outer track squares because the design places each player's home quadrant at a corner, with the entry/start square for each color positioned 13 squares apart around the perimeter. Since 52 = 4 × 13, and there are four players, each player gets exactly 13 unique squares on the outer track before reaching the next player's start square. This symmetrical spacing is what allows all four players to share the same track without collision, while each player still has a distinct path. The 52 squares plus 5 home column squares means each piece travels exactly 57 positions from base to finish.
A piece enters its home column when its track position crosses from 51 (the last outer track square) into 52 (the first home column square). The home column is 5 squares long, with positions 52 through 56 representing each step toward the center. Position 57 is the finish. For example, a Red piece that is on track position 50 and rolls a 2 advances to position 52, entering the home column. From there, the remaining distance to the finish is 57 minus the current position. The home column is private — no opponent can enter it or capture pieces within it. The entry point into the home column is unique per player: Red enters from (6,0), Green from (0,14), Yellow from (0,8), and Blue from (8,0).
Under standard Ludo rules, two pieces from the same player cannot occupy the same square simultaneously. This is enforced in the getMoveCandidates function by checking whether any of the player's other pieces already occupy the target position before considering a move valid. However, some digital variants relax this rule to allow "stacking," which can be a useful strategic option. If you implement stacking, you must adjust your capture logic: a stack is only captured if the capturing piece lands on it, sending all stacked pieces back to base at once. The self-collision check uses player.pieces.some(p => p.trackPos === targetPos) and must be updated if stacking is enabled.
Rolling three consecutive 6s immediately forfeits the current turn. The player's move is cancelled at the moment the third 6 is rolled, no piece moves, and the dice value is discarded. Control passes to the next player. The probability of three consecutive 6s is (1/6)³ = 1/216 ≈ 0.46%, making it rare but significant when it happens. In the animation system, the third 6 should trigger a special "forfeit" animation — a red flash on the player's quadrant and a dice shake effect. In the state machine, you must track consecutive sixes with a counter that resets whenever a non-6 is rolled. The rule prevents players from repeatedly rolling 6 to gain unlimited turns, which would unbalance the game.
If a piece is at position 56 (one step from the center), it cannot move with a dice value greater than 1 because 56 + 6 = 62, which exceeds WIN_POSITION (57). The isValidMove check explicitly enforces this: piece.trackPos + diceValue <= 57. The piece stays at position 56 and the turn proceeds normally — the dice value is effectively wasted for that piece. The player may still have other movable pieces. If all movable pieces are blocked and the player has no valid moves, the turn passes to the next player, even if the rolled value was a 6. This rule is what makes "racing for the finish" exciting — a player with multiple near-finish pieces might repeatedly waste high rolls.
For a smooth piece movement animation, the system animates each square individually with a per-square duration (default: 120ms). A move of 6 squares would take 720ms of animation time plus easing overhead. The total animation budget per turn should be capped to prevent slow perceived gameplay — a practical maximum is around 2 seconds for any single move sequence. Use easeInOutQuad interpolation between squares so the piece accelerates and decelerates naturally rather than moving at constant speed. For the dice roll animation, a minimum of 800ms with 30fps face cycling feels physically satisfying and gives players time to anticipate the result. The LudoAnimationController queues animations sequentially and fires events on completion, allowing the game logic to proceed only after all visual updates are rendered.

Building a Ludo Game?

Get help with board coordinates, movement algorithms, dice probability analysis, and animation implementation for your Ludo game.