androidinterview.com

Low Level Design (LLD) Interview Questions

Design Ludo

Tier: EssentialDifficulty: MediumAsked of: Mid, SeniorAsked at: Freshworks

Ludo is snake and ladder with a choice. In snake and ladder a roll tells you exactly what happens. In ludo a roll of six with two tokens in the yard and two on the track gives you four different legal moves, and somebody has to pick one. Everything interesting in this design comes from that one difference.

So the shape is two pieces. A MoveValidator takes a player and a roll and hands back every legal move. A MoveSelectionStrategy takes that list and returns the one that gets played. The turn loop does neither, it just asks each of them once.

The second decision is how a token stores where it is. Store steps travelled, not a board cell. That is what makes four colours share one track and what makes the home stretch stop being a special case.

State the rule set first

Ludo rules vary by region, and a design written against unstated rules cannot be reviewed by anyone. Say which rules you are building to before you draw a class. This is the set used here.

  • Two to four players, one colour each, four tokens each. All tokens start in the yard.
  • A token leaves the yard only on a roll of six, landing on that colour's entry cell.
  • Tokens travel clockwise around a shared track of 52 cells, then up a private home column of 6 cells.
  • The full journey is 57 steps. Steps 0 to 50 are on the shared track, steps 51 to 56 are the home column, and step 56 is home.
  • An exact roll is needed to land on home. A roll that would overshoot is not a legal move, so it is never offered.
  • Landing on an unsafe cell holding opponent tokens captures all of them and sends them back to their yard.
  • Eight cells are safe, the four entry cells and the four star cells. No capture happens on a safe cell.
  • Your own tokens may share a cell. This rule set has no blocking, opponents pass through freely.
  • A six earns another roll. A capture earns another roll. Three sixes in a row forfeits the turn and the third roll is not played.
  • If a roll produces no legal move, the turn passes.
  • The first player to get all four tokens home wins and the game stops.

Every one of those lines is a question worth asking the interviewer out loud. Blocking and extra turns in particular are the ones that differ most between households, let alone regions.

What this really tests

Whether you can take a set of fiddly, half remembered rules and put them somewhere they can be read and changed. The trap is a turn method with sixty lines of nested conditions in it. The pass is a class whose only job is to produce legal moves, and a turn loop short enough to read in one screen.

Why steps travelled and not a board cell

This is the decision the whole design rests on, so it is worth being slow about.

Red joins the shared track at cell 0. Green joins at cell 13. If a token stores the absolute cell it is standing on, then every rule needs to know the colour. How far is this token from home is a different subtraction for each colour, with a wraparound in the middle for three of them. Where does the home column start is another per colour number. Has this token gone all the way round yet needs you to remember where it started.

Store the number of steps the token has travelled instead, and all four colours become identical. Zero steps is the entry cell. Fifty steps is the last shared cell. Fifty one is the first cell of the home column. Fifty six is home. Every rule is now arithmetic on one number, and the same arithmetic works for red, green, yellow and blue.

The home column stops being a special case as well. It is just the part of the number line past fifty, and only one line in the code knows that, the one that decides a token past fifty can no longer be captured.

You only need the absolute cell for one question, are two tokens standing on the same square. So there is exactly one function that converts, entry cell of the colour plus steps travelled, modulo 52. That single conversion is the only per colour arithmetic in the codebase.

Notice again that this made things smaller. That is the pattern across all three game problems. The right modelling decision removes cases, it does not add layers.

The classes

Eight types, and each one exists for a stated reason.

  • Color carries the four colours and the cell where each joins the shared track. It is the only place a per colour number lives.
  • Token holds its colour, an id and its steps travelled. The four states, in the yard, on the track, in the home column, and home, are computed from the step count rather than stored. A stored state field would be a second source of truth that can drift out of step with the number.
  • Board is geometry. It converts a colour and a step count into a shared cell, and it says whether a cell is safe. The safe cell set is a constructor argument, because that is exactly the rule people disagree about.
  • Move is a candidate. It holds the token, the step count before, the step count after, and the list of tokens it would capture. Working the capture out when the move is created rather than when it is applied means a bot can score a move without touching the board, and the game does not repeat the work.
  • MoveValidator produces every legal move for a player and a roll. Every rule in the game lives here. This is the class that makes or breaks the answer.
  • MoveSelectionStrategy picks one move from the list. A human implementation asks a person, a bot implementation scores and sorts. The game never knows which it called.
  • Player is a name, a colour, four tokens and a strategy. Putting the strategy on the player is what lets a person and three bots share a table with no branching in the loop.
  • Game owns the turn queue, the dice, extra turns and the win check. It is short, and its shortness is the evidence that the validator and the strategy are doing their jobs.

The dependencies all run one way, from the loop down to the rules and from the rules down to the tokens.

Ludo class diagramClasses Game, MoveValidator, Player, MoveSelectionStrategy, Board, Move, Token, Color. Game asks 1 MoveValidator. Game aggregates 2..4 Player. Player chooses with 1 MoveSelectionStrategy. Player is composed of 4 Token. MoveValidator geometry Board. MoveValidator reads Token. MoveValidator produces 0..* Move. Move moves Token. Token is associated with Color.
Ludo class diagram, a UML class diagram of Game, MoveValidator, Player, MoveSelectionStrategy, Board, Move, Token, Color
The tokens are the only things that change during a game, and everything above them is a rule that reads a step count and hands back a list of candidate moves.

One turn, walked through

A red player has two tokens in the yard, one on step 4 and one on step 49. A green token is sitting six cells ahead of the red token on step 4, on an unsafe cell. Red rolls a six.

  1. The game takes red off the front of the turn queue and rolls. A six, so the consecutive six counter goes to one. Not three, so the turn continues.
  2. The game asks the validator for legal moves. The validator walks red's four tokens.
  3. The two tokens in the yard can move, because the roll is a six. Each produces a move from the yard to step 0.
  4. The token on step 4 can move to step 10. The validator converts step 10 into a shared cell, sees the green token standing there, checks the cell is not safe, and attaches that green token to the move as a capture.
  5. The token on step 49 would go to step 55, inside the home column and not past step 56, so it is legal and it cannot capture anything, because the home column is private. The validator returns all four moves as a list, and that list is the whole point of the class.
  6. The game hands the list to red's strategy. An aggressive bot scores the capture highest and returns it. A human strategy would print the four options and wait.
  7. The game applies the chosen move. The red token goes to step 10 and the captured green token goes back to its yard, its step count reset.
  8. Red does not have all four tokens home, so the game is not over. The roll was a six and the move was a capture, so red rolls again rather than being pushed back onto the queue.

Change one thing and the other important path appears. Suppose red had rolled a three with all four tokens in the yard. The validator would return an empty list. That is not an error, it is the answer. The turn simply passes and red goes to the back of the queue. Forgetting that empty is a legal outcome is the most common bug in a ludo implementation.

Patterns actually used

Two carry the design and the rest are noise here.

  • Strategy for move selection. This is the one. Ludo's entire design tension is that a roll produces choices, and this is where the choice lives. Without it, human input and bot logic both end up inside the turn loop, and adding a second difficulty means editing the loop.
  • A rule engine, or a specification if you prefer the name. The validator is one place where the rules are written down. Without it the same rules are spread across a turn method as nested conditions, and the interviewer's next question, what if a capture does not grant an extra turn, means finding all of them.
  • The move as a value object. It is halfway to a command. If undo or replay is asked for, the move already carries everything needed to reverse itself, the token, both step counts and the captured tokens. Only call it Command if they ask for undo, otherwise it is just a well chosen value type.

Patterns to leave out, and to say you are leaving out.

  • State classes per token state. Four states with no per state behaviour, because the behaviour is validation and that already lives in the validator. Derived properties on the token are enough.
  • A factory for tokens. Four tokens in a loop is not a creational problem.
  • Observer for captures and wins. Worth one sentence about a networked version and nothing more in a local game.

The implementation

The Java is around 340 lines and the Kotlin around 200, and the turn loop is under thirty in both. That is the measure of whether the split worked.

The Kotlin is written as Kotlin. The dice and the selection strategy are function types rather than interfaces, the legal move list is built with mapNotNull over a when rather than a loop with continues, capture detection is a filter, and the token states are computed properties. The safe cell set is a default argument, so the standard board costs nothing and a variant board is one parameter.

Java

com.androidinterview.ludo.game.Game.java

package com.androidinterview.ludo.game;

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;
import java.util.function.IntSupplier;

import com.androidinterview.ludo.model.Move;
import com.androidinterview.ludo.model.Token;
import com.androidinterview.ludo.rules.MoveValidator;

// The turn loop, and it stays short because it delegates twice. The validator
// says what is legal and the strategy says what is played, so this class only
// owns turn order, extra turns and the win check.
public final class Game {

    private final MoveValidator validator;
    private final IntSupplier dice;
    private final Deque<Player> turnOrder = new ArrayDeque<>();
    private final List<Token> allTokens;

    private Player winner;

    public Game(MoveValidator validator, IntSupplier dice, List<Player> players) {
        this.validator = validator;
        this.dice = dice;
        this.turnOrder.addAll(players);
        this.allTokens = players.stream().flatMap(player -> player.tokens().stream()).toList();
    }

    public Player winner() {
        return winner;
    }

    public void play() {
        while (winner == null) {
            playTurn();
        }
    }

    // One player's whole turn, including any extra rolls a six or a capture
    // earns them.
    public void playTurn() {
        Player player = turnOrder.poll();
        int sixes = 0;
        boolean rollAgain = true;

        while (rollAgain) {
            int roll = dice.getAsInt();
            sixes = roll == 6 ? sixes + 1 : 0;
            if (sixes == 3) {
                // Three sixes forfeits the turn and the third roll is not
                // played at all.
                break;
            }
            List<Move> legal = validator.legalMoves(player.tokens(), allTokens, roll);
            if (legal.isEmpty()) {
                // No legal move is a normal outcome, not an error. Forgetting
                // this is the most common bug in a ludo implementation.
                break;
            }
            Move move = player.strategy().choose(legal);
            apply(move);
            if (player.hasWon()) {
                winner = player;
                return;
            }
            rollAgain = roll == 6 || move.isCapture();
        }
        turnOrder.add(player);
    }

    private void apply(Move move) {
        move.token().moveTo(move.toSteps());
        move.captured().forEach(Token::returnToYard);
    }
}

com.androidinterview.ludo.game.Player.java

package com.androidinterview.ludo.game;

import java.util.List;
import java.util.stream.IntStream;

import com.androidinterview.ludo.model.Color;
import com.androidinterview.ludo.model.Token;
import com.androidinterview.ludo.rules.MoveSelectionStrategy;

// A player is a colour, four tokens and a way of choosing. The choosing is a
// strategy, so a person and three bots of different difficulty sit at the same
// table with no branching anywhere in the game.
public record Player(String name, Color color, List<Token> tokens, MoveSelectionStrategy strategy) {

    public static Player of(String name, Color color, MoveSelectionStrategy strategy) {
        return new Player(name, color, IntStream.range(0, 4).mapToObj(id -> new Token(color, id)).toList(), strategy);
    }

    public boolean hasWon() {
        return tokens.stream().allMatch(Token::isHome);
    }
}

com.androidinterview.ludo.model.Board.java

package com.androidinterview.ludo.model;

import java.util.Set;

// The geometry, and nothing else. It answers one question that matters, which
// shared cell is this token standing on, and one that supports a rule variant,
// is that cell safe.
public final class Board {

    public static final int TRACK_CELLS = 52;

    // Steps 0 to 50 are the shared track, steps 51 to 56 are the private home
    // column, and step 56 is home. One number line for the whole journey.
    public static final int LAST_TRACK_STEP = 50;
    public static final int HOME_STEP = 56;

    private final Set<Integer> safeCells;

    public Board() {
        // The four entry cells and the four star cells eight ahead of them.
        this(Set.of(0, 8, 13, 21, 26, 34, 39, 47));
    }

    public Board(Set<Integer> safeCells) {
        this.safeCells = safeCells;
    }

    // The only place steps become a cell, and only for a token still on the
    // shared track. Two colours collide when this returns the same number.
    public int cellAt(Color color, int steps) {
        return (color.entryCell() + steps) % TRACK_CELLS;
    }

    public int cellOf(Token token) {
        return cellAt(token.color(), token.steps());
    }

    public boolean isSafe(int cell) {
        return safeCells.contains(cell);
    }
}

com.androidinterview.ludo.model.Color.java

package com.androidinterview.ludo.model;

// A colour ties a player, four tokens, a yard and a home column together. It
// also carries where that colour joins the shared track, which is the only
// per colour number the rest of the design needs.
public enum Color {
    RED(0),
    GREEN(13),
    YELLOW(26),
    BLUE(39);

    private final int entryCell;

    Color(int entryCell) {
        this.entryCell = entryCell;
    }

    public int entryCell() {
        return entryCell;
    }
}

com.androidinterview.ludo.model.Move.java

package com.androidinterview.ludo.model;

import java.util.List;

// A candidate move, already knowing what it would capture. The validator works
// that out once, so the game does not have to recompute it when it applies the
// move, and a bot can score the move without touching the board.
public record Move(Token token, int fromSteps, int toSteps, List<Token> captured) {

    public boolean isCapture() {
        return !captured.isEmpty();
    }
}

com.androidinterview.ludo.model.Token.java

package com.androidinterview.ludo.model;

// A token stores how far it has travelled, not which cell it is standing on.
// Every colour enters the track at a different place, so an absolute cell
// would force per colour arithmetic into every rule. Steps travelled makes all
// four colours identical, and it makes the home column a continuation of the
// same number line rather than a special case.
public final class Token {

    public static final int YARD = -1;

    private final Color color;
    private final int id;
    private int steps = YARD;

    public Token(Color color, int id) {
        this.color = color;
        this.id = id;
    }

    public Color color() {
        return color;
    }

    public int id() {
        return id;
    }

    public int steps() {
        return steps;
    }

    // The four token states are derived, never stored. A stored state field is
    // a second source of truth that can disagree with the step count.
    public boolean isInYard() {
        return steps == YARD;
    }

    public boolean isHome() {
        return steps == Board.HOME_STEP;
    }

    public boolean isOnSharedTrack() {
        return steps >= 0 && steps <= Board.LAST_TRACK_STEP;
    }

    public void moveTo(int steps) {
        this.steps = steps;
    }

    public void returnToYard() {
        this.steps = YARD;
    }

    @Override
    public String toString() {
        return color + " " + id;
    }
}

com.androidinterview.ludo.rules.MoveSelectionStrategy.java

package com.androidinterview.ludo.rules;

import java.util.Comparator;
import java.util.List;

import com.androidinterview.ludo.model.Move;
import com.androidinterview.ludo.model.Token;

// The choice, which is the whole thing that makes ludo harder than snake and
// ladder. The validator says which moves are legal, this says which one is
// played, and swapping a person for a bot changes nothing else.
@FunctionalInterface
public interface MoveSelectionStrategy {

    // The caller guarantees the list is not empty. An empty list is a passed
    // turn and the game loop deals with it, so no strategy has to.
    Move choose(List<Move> legalMoves);

    // Deterministic, so a game replays identically in a test. A bot that needs
    // a seeded random generator to be testable is a worse bot.
    static MoveSelectionStrategy firstLegal() {
        return moves -> moves.get(0);
    }

    // Take a capture, otherwise get a token out of the yard, otherwise push
    // the token that is furthest along. Three lines of heuristic, and it plays
    // a recognisable game.
    static MoveSelectionStrategy aggressive() {
        return moves -> moves.stream().max(Comparator.comparingInt(MoveSelectionStrategy::score)).orElseThrow();
    }

    private static int score(Move move) {
        if (move.isCapture()) {
            return 200 + move.captured().size();
        }
        if (move.fromSteps() == Token.YARD) {
            return 100;
        }
        return move.toSteps();
    }
}

com.androidinterview.ludo.rules.MoveValidator.java

package com.androidinterview.ludo.rules;

import java.util.ArrayList;
import java.util.List;

import com.androidinterview.ludo.model.Board;
import com.androidinterview.ludo.model.Move;
import com.androidinterview.ludo.model.Token;

// Every rule in the game is in this one class. Keeping it out of the game loop
// is the biggest structural decision in the design, because it turns a sixty
// line if chain into a list the caller can look at, score and choose from.
public final class MoveValidator {

    private final Board board;

    public MoveValidator(Board board) {
        this.board = board;
    }

    // The list may be empty, and that is a real outcome, not an error. All
    // four tokens in the yard and a roll of three means the turn simply passes.
    public List<Move> legalMoves(List<Token> playerTokens, List<Token> allTokens, int roll) {
        List<Move> moves = new ArrayList<>();
        for (Token token : playerTokens) {
            if (token.isHome()) {
                continue;
            }
            if (token.isInYard()) {
                // A six is the only way out of the yard, onto step zero, which
                // is this colour's entry cell.
                if (roll == 6) {
                    moves.add(candidate(token, 0, allTokens));
                }
                continue;
            }
            int destination = token.steps() + roll;
            // The exact finish rule. Overshooting home is not a legal move, so
            // it never reaches the player as an option at all.
            if (destination <= Board.HOME_STEP) {
                moves.add(candidate(token, destination, allTokens));
            }
        }
        return moves;
    }

    private Move candidate(Token token, int destination, List<Token> allTokens) {
        return new Move(token, token.steps(), destination, capturesAt(token, destination, allTokens));
    }

    private List<Token> capturesAt(Token mover, int destination, List<Token> allTokens) {
        // The home column is private, so nothing can be captured there.
        if (destination > Board.LAST_TRACK_STEP) {
            return List.of();
        }
        int cell = board.cellAt(mover.color(), destination);
        if (board.isSafe(cell)) {
            return List.of();
        }
        List<Token> captured = new ArrayList<>();
        for (Token other : allTokens) {
            if (other.color() != mover.color() && other.isOnSharedTrack() && board.cellOf(other) == cell) {
                captured.add(other);
            }
        }
        return captured;
    }
}

Kotlin

com.androidinterview.ludo.game.Game.kt

package com.androidinterview.ludo.game

import com.androidinterview.ludo.model.Color
import com.androidinterview.ludo.model.Move
import com.androidinterview.ludo.model.Token
import com.androidinterview.ludo.rules.MoveSelectionStrategy
import com.androidinterview.ludo.rules.MoveValidator
import kotlin.random.Random

typealias Dice = () -> Int

fun dice(random: Random = Random.Default): Dice = { random.nextInt(1, 7) }

// A player is a colour, four tokens and a way of choosing. Choosing is a
// strategy, so a person and three bots of different difficulty sit at one
// table with no branching in the game.
class Player(val name: String, val color: Color, val strategy: MoveSelectionStrategy) {
    val tokens = List(4) { id -> Token(color, id) }
    val hasWon get() = tokens.all { it.isHome }
}

// The turn loop, short because it delegates twice. The validator says what is
// legal and the strategy says what is played, so this class owns turn order,
// extra turns and the win check, and nothing else.
class Game(
    private val validator: MoveValidator,
    private val dice: Dice,
    players: List<Player>,
) {

    private val turnOrder = ArrayDeque(players)
    private val allTokens = players.flatMap { it.tokens }

    var winner: Player? = null
        private set

    fun play() {
        while (winner == null) playTurn()
    }

    // One player's whole turn, including any extra rolls a six or a capture
    // earns them.
    fun playTurn() {
        val player = turnOrder.removeFirst()
        var sixes = 0
        var rollAgain = true

        while (rollAgain) {
            val roll = dice()
            sixes = if (roll == 6) sixes + 1 else 0
            // Three sixes forfeits the turn and the third roll is not played.
            if (sixes == 3) break

            val legal = validator.legalMoves(player.tokens, allTokens, roll)
            // No legal move is a normal outcome, not an error. Forgetting this
            // is the most common bug in a ludo implementation.
            if (legal.isEmpty()) break

            val move = player.strategy(legal)
            apply(move)
            if (player.hasWon) {
                winner = player
                return
            }
            // A six or a capture earns one more roll, never two.
            rollAgain = roll == 6 || move.isCapture
        }
        turnOrder.addLast(player)
    }

    private fun apply(move: Move) {
        move.token.moveTo(move.toSteps)
        move.captured.forEach(Token::returnToYard)
    }
}

com.androidinterview.ludo.model.Board.kt

package com.androidinterview.ludo.model

// A colour ties a player, four tokens, a yard and a home column together, and
// carries the one per colour number the rest of the design needs, where that
// colour joins the shared track.
enum class Color(val entryCell: Int) {
    RED(0),
    GREEN(13),
    YELLOW(26),
    BLUE(39),
}

const val YARD = -1

// Steps 0 to 50 are the shared track, 51 to 56 are the private home column,
// and 56 is home. One number line for the whole journey.
const val LAST_TRACK_STEP = 50
const val HOME_STEP = 56
const val TRACK_CELLS = 52

// A token stores how far it has travelled, not the cell it stands on. Each
// colour joins the track somewhere different, so an absolute cell would push
// per colour arithmetic into every rule. Steps make all four colours identical
// and turn the home column into more of the same number line.
class Token(val color: Color, val id: Int) {

    var steps: Int = YARD
        private set

    // The four states are derived, never stored. A stored state field is a
    // second source of truth that can disagree with the step count.
    val isInYard get() = steps == YARD
    val isHome get() = steps == HOME_STEP
    val isOnSharedTrack get() = steps in 0..LAST_TRACK_STEP

    fun moveTo(steps: Int) {
        this.steps = steps
    }

    fun returnToYard() {
        steps = YARD
    }

    override fun toString() = "$color $id"
}

// A candidate move that already knows what it would capture. The validator
// works that out once, so the game does not repeat it and a bot can score a
// move without touching the board.
data class Move(val token: Token, val fromSteps: Int, val toSteps: Int, val captured: List<Token>) {
    val isCapture get() = captured.isNotEmpty()
}

// The geometry, and nothing else. Safe cells are a constructor argument
// because which cells are safe is exactly the rule that varies by region.
class Board(private val safeCells: Set<Int> = setOf(0, 8, 13, 21, 26, 34, 39, 47)) {

    // The only place steps become a cell, and only for a token still on the
    // shared track. Two colours collide when this returns the same number.
    fun cellAt(color: Color, steps: Int) = (color.entryCell + steps) % TRACK_CELLS

    fun cellOf(token: Token) = cellAt(token.color, token.steps)

    fun isSafe(cell: Int) = cell in safeCells
}

com.androidinterview.ludo.rules.Rules.kt

package com.androidinterview.ludo.rules

import com.androidinterview.ludo.model.Board
import com.androidinterview.ludo.model.HOME_STEP
import com.androidinterview.ludo.model.LAST_TRACK_STEP
import com.androidinterview.ludo.model.Move
import com.androidinterview.ludo.model.Token
import com.androidinterview.ludo.model.YARD

// The choice, which is the whole thing that makes ludo harder than snake and
// ladder. The validator says what is legal, this says what is played, and it
// is a function type because it has one job.
//
// The caller guarantees the list is not empty. An empty list is a passed turn
// and the game loop deals with it, so no strategy has to.
typealias MoveSelectionStrategy = (legalMoves: List<Move>) -> Move

// Deterministic, so a game replays identically in a test. A bot that needs a
// seeded random generator to be testable is a worse bot.
val FirstLegal: MoveSelectionStrategy = { moves -> moves.first() }

// Take a capture, otherwise leave the yard, otherwise push the token furthest
// along. Three lines of heuristic and it plays a recognisable game.
val Aggressive: MoveSelectionStrategy = { moves ->
    moves.maxBy { move ->
        when {
            move.isCapture -> 200 + move.captured.size
            move.fromSteps == YARD -> 100
            else -> move.toSteps
        }
    }
}

// Every rule in the game is in this one class. Keeping it out of the turn loop
// is the biggest structural decision here, because it turns a sixty line if
// chain into a list the caller can look at, score and choose from.
class MoveValidator(private val board: Board) {

    // The list may be empty, and that is a real outcome rather than an error.
    // Four tokens in the yard and a roll of three means the turn passes.
    fun legalMoves(playerTokens: List<Token>, allTokens: List<Token>, roll: Int): List<Move> =
        playerTokens.mapNotNull { token ->
            when {
                token.isHome -> null
                // A six is the only way out of the yard, onto step zero, which
                // is this colour's entry cell.
                token.isInYard -> if (roll == 6) token.candidate(0, allTokens) else null
                // The exact finish rule. Overshooting home is not legal, so it
                // never reaches the player as an option at all.
                token.steps + roll <= HOME_STEP -> token.candidate(token.steps + roll, allTokens)
                else -> null
            }
        }

    private fun Token.candidate(destination: Int, allTokens: List<Token>) =
        Move(this, steps, destination, capturesAt(destination, allTokens))

    private fun Token.capturesAt(destination: Int, allTokens: List<Token>): List<Token> {
        // The home column is private, so nothing can be captured there.
        if (destination > LAST_TRACK_STEP) return emptyList()
        val cell = board.cellAt(color, destination)
        if (board.isSafe(cell)) return emptyList()
        return allTokens.filter { it.color != color && it.isOnSharedTrack && board.cellOf(it) == cell }
    }
}

Concurrency and edge cases

A local game is turn based and single threaded, exactly like snake and ladder. Do not invent thread safety for it.

Online multiplayer is a fair follow up, and the answer is four short points. Serialise each game behind one lock or one actor, so moves in a session are applied in order. Carry a turn token, so a move from anyone but the current player is rejected rather than raced. Give every move a sequence number, so a client retry after a dropped connection is idempotent rather than played twice. Run a turn timer that auto passes, because somebody always closes the app mid game. Note that the token list the validator reads is fixed for the life of a game, so a player who drops out leaves their tokens on the board rather than shrinking it.

The edge cases that decide whether the design is right.

  • No legal move. The turn passes. Handled by the validator returning an empty list and the loop treating that as normal.
  • A capture on a safe cell. Does not happen. The check is in one place, so the rule can be turned off for a variant by changing the safe cell set.
  • An exact roll to reach home. Enforced by never generating the illegal move, rather than by generating it and rejecting it later.
  • Three sixes in a row. The turn is forfeited and the third roll is not played at all.
  • A capture and a six together. One extra turn, not two. The loop rolls again, it does not queue up two rolls.
  • The winning move on a six. The player has already won and the game stops, so the extra roll never happens.
  • Two of your own tokens on one cell. Legal in this rule set. If the interviewer wants blocks, it is a new check inside the validator and nothing else changes, which is the argument for having the validator at all.
  • A token in the home column. Cannot be captured, and cannot capture. One comparison against the last shared step covers both.
  • Two strategically identical moves. A bot still has to pick one deterministically, otherwise the same game replays differently and no test can assert anything.