androidinterview.com

Low Level Design (LLD) Interview Questions

Design Snake and Ladder

Tier: EssentialDifficulty: EasyAsked of: Junior, MidAsked at: Amazon, Adobe, Microsoft, Flipkart, Swiggy, Zynga

Model a snake and a ladder as the same thing. Both are a jump from one cell to another. A snake happens to go down and a ladder happens to go up, and nothing else about them differs. Make that one decision and your turn loop has no branching in it at all.

That is the whole answer, and it is the thing the interviewer is watching for. This problem has almost no design pattern content, which is why padding it with patterns hurts you. It is a code quality question wearing a game costume.

What this really tests

Whether you can find the abstraction that removes work rather than adding structure. It is also the most common machine coding round problem there is, so the second thing being tested is whether you read the requirements properly, in particular the exact finish rule and chained jumps.

What to clarify first

Four of these change the code, so ask before you write.

  • Is the finish exact. Almost always yes. A roll that would take you past the last cell does not move you at all. This is the single most missed requirement in the whole problem.
  • Do jumps chain. If a ladder lands you on a snake head, do you slide. Usually yes, and it changes the board lookup from an if into a loop.
  • Does the game stop at the first winner, or do the rest keep playing for a ranking. Both are one line, but only one of them is what they asked for.
  • How many players, and how many dice. Two players and one die is the default. Both should be parameters anyway.
  • Any turn rules. Rolling a six usually earns another roll, and three sixes in a row usually cancels the turn.
  • Is the board fixed at a hundred cells. Make it a field regardless.

Why one BoardEntity instead of Snake and Ladder

Picture the version with two classes. The board holds a list of snakes and a list of ladders. After every roll the turn loop asks whether any snake head matches the new cell, then whether any ladder bottom matches it, then applies whichever hit. That is two scans, two branches, and two code paths that have to stay in step.

Now add chained jumps. A ladder can drop you on a snake head, so you have to repeat both scans in a loop until nothing matches. The loop now contains four things, two lookups and two branches, and every one of them is a place to introduce a bug in front of an interviewer.

With one type it collapses. The board flattens every entity into a single map from start cell to destination cell when it is built. Resolving a landing is one map lookup, repeated while the map keeps answering. The turn loop never learns that snakes exist. Adding a teleporter that moves you sideways is a new entry in the same map and no code changes.

Notice this made the code smaller. That is the tell for a good decision in this problem. If your abstraction adds classes without removing branches, it is decoration.

The subclasses are worth one more sentence, because most write ups do use them. An abstract BoardEntity with a Snake and a Ladder subclass is fine as long as the game loop never asks which one it has. If nothing ever calls a method that behaves differently on the two, the subclasses are carrying no behaviour, and two static factory methods on one type give you the same readable construction and the same validation with half the code.

The classes

Five types, and that is genuinely all of them.

  • BoardEntity is a start cell and an end cell. Two factory methods build it, one for a snake and one for a ladder, and each checks its own direction. That validation is the only place the difference between a snake and a ladder exists.
  • Board owns the size and the map from cell to destination. It exposes one interesting method, where does a landing on this cell actually put you, and that method resolves chains in a loop. It is a map lookup, not a list scan, and saying so out loud is worth doing. It is also where every cell gets range checked, because the board is the only object that knows how big it is and an entity does not.
  • Dice holds the number of dice and rolls them. The count is a field, not a constant, which is why the two dice variant is a one line change. In Kotlin it is a function type instead of a class, so the count is captured in the lambda.
  • Player is a name and a current cell. Nothing else. Rules about turns belong to the game.
  • Game owns the turn queue, the dice, the board and the winner. It runs the loop.

Turn order is a queue rather than an index into a list. Taking the front player and pushing them back at the end of their turn is round robin with no arithmetic, and it means removing a player mid game costs nothing. If you later want a full ranking instead of a single winner, you stop pushing finished players back and run until one is left, which is the queue paying for itself.

Draw this one at the whiteboard for what is missing from it as much as for what is in it.

Snake and ladder class diagramClasses Game, Board, Dice, Player, BoardEntity. Game is composed of 1 Board. Game is composed of 1 Dice. Game aggregates 2..* Player. Board is composed of 0..* BoardEntity.
Snake and ladder class diagram, a UML class diagram of Game, Board, Dice, Player, BoardEntity
One type carries both directions, so nothing above the board ever branches on whether a cell holds a snake or a ladder.

One turn, walked through

A player is on cell 94, there is a ladder from 97 to 99 and a snake from 99 back to 21.

  1. The game takes the front player off the queue and remembers where they started, because the three sixes rule may have to undo the whole turn.
  2. It rolls. Say a three. The counter of consecutive sixes resets to zero.
  3. The target cell is 94 plus 3, which is 97. That is not past 100, so the move is allowed.
  4. The game asks the board where a landing on 97 leads. The board finds 97 in its map and gets 99. It looks up 99 and finds the snake, so it gets 21. It looks up 21, finds nothing, and returns 21. Two hops, one loop, no snake branch anywhere.
  5. The player moves to 21 and the game reports the move line. The player is not on the last cell, and the roll was not a six, so the turn is over.
  6. The player goes back to the end of the queue and the next player is taken off the front.

Now change one thing. The player was on 98 and rolled a four. The target is 102, which is past the end, so the player does not move at all. Not to 100, not partway. They stay on 98 and the turn ends. Getting that wrong is the most common way to fail this question.

Patterns actually used

Be honest here. Reaching for patterns you do not need is a worse answer than admitting there are few.

  • One type for both entities. Call it what it is, a shared abstraction that removes branching. If the interviewer wants the name, an abstract base with two subclasses is the template method shape, but the value is the unification, not the inheritance.
  • A factory for the board, if and only if they ask for randomly generated snakes and ladders. Then a method that produces a valid board, no cycles and no duplicate start cells, earns its place. Otherwise there is nothing to make.
  • A singleton game manager, if and only if they ask for several games at once. It holds the sessions and hands each one a thread.

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

  • Strategy for the dice or the win condition. Legitimate if variants are asked for, but leading with it on a problem this small looks like pattern hunting.
  • Observer for move announcements. A print statement in a console game does not need a listener list.
  • A state class per game state. Not started, running and finished have no per state behaviour worth a class.

The implementation

The Java is under two hundred lines and the Kotlin is close to a hundred, and that is not because anything was left out. It is what the unified entity buys you.

The Kotlin is written as Kotlin. The dice is a function type rather than a class, so the number of dice is captured in a lambda. Chain resolution is a tailrec function, which is the honest shape for follow the pointer until it stops. The turn loop is a do while whose condition reads the roll declared inside the body, so there is no flag variable.

Java

com.androidinterview.snakeandladder.game.Game.java

package com.androidinterview.snakeandladder.game;

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

import com.androidinterview.snakeandladder.model.Board;
import com.androidinterview.snakeandladder.model.Dice;
import com.androidinterview.snakeandladder.model.Player;

// The whole game. A queue for turn order, one roll, one move, one win check.
// There is no snake handling and no ladder handling in here, because the board
// already resolved both into a destination.
public final class Game {

    private final Board board;
    private final Dice dice;
    private final Deque<Player> turnOrder = new ArrayDeque<>();
    private final Consumer<String> log;

    private Player winner;

    public Game(Board board, Dice dice, List<Player> players) {
        this(board, dice, players, System.out::println);
    }

    // The commentary goes to a listener rather than straight to stdout, so the
    // same game runs behind a socket, in a test or in a UI without anyone
    // capturing a stream.
    public Game(Board board, Dice dice, List<Player> players, Consumer<String> log) {
        this.board = board;
        this.dice = dice;
        this.log = log;
        this.turnOrder.addAll(players);
    }

    public boolean isOver() {
        return winner != null;
    }

    public Player winner() {
        return winner;
    }

    public void play() {
        while (!isOver()) {
            playTurn();
        }
        log.accept(winner.name() + " wins the game");
    }

    // One player's whole turn. A six earns another roll, and three sixes in a
    // row cancels the turn, which is why the starting cell is kept.
    public void playTurn() {
        Player player = turnOrder.poll();
        int startedAt = player.position();
        int sixes = 0;
        boolean rollAgain = true;

        while (rollAgain) {
            int roll = dice.roll();
            sixes = roll == 6 ? sixes + 1 : 0;
            if (sixes == 3) {
                player.moveTo(startedAt);
                break;
            }
            move(player, roll);
            if (player.position() == board.size()) {
                winner = player;
                // The winner is not pushed back on the queue. Keeping the rest
                // going until one player is left is what turns this into a
                // ranking, and it costs this one return.
                return;
            }
            rollAgain = roll == 6;
        }
        turnOrder.add(player);
    }

    private void move(Player player, int roll) {
        int from = player.position();
        int target = from + roll;
        // The exact finish rule, and the requirement most people miss. A roll
        // that would overshoot the last cell does not move the player at all.
        int to = target > board.size() ? from : board.destinationFrom(target);
        player.moveTo(to);
        log.accept(player.name() + " rolled a " + roll + " and moved from " + from + " to " + to);
    }
}

com.androidinterview.snakeandladder.model.Board.java

package com.androidinterview.snakeandladder.model;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

// Cell to destination, one map. A list of snakes and a list of ladders would
// mean scanning both on every move, so the entities are flattened into a
// lookup once at construction and never scanned again.
public final class Board {

    private final int size;
    private final Map<Integer, Integer> jumps = new HashMap<>();

    public Board(int size, List<BoardEntity> entities) {
        this.size = size;
        for (BoardEntity entity : entities) {
            requireOnBoard(entity.start());
            requireOnBoard(entity.end());
            if (entity.start() == size) {
                throw new IllegalArgumentException("Nothing may start on the last cell, " + entity.start());
            }
            if (jumps.put(entity.start(), entity.end()) != null) {
                throw new IllegalArgumentException("Two entities start at cell " + entity.start());
            }
        }
    }

    // The board is the only object that knows how big it is, so the range check
    // belongs here rather than on the entity. A ladder to 105 leaves a player on
    // a cell that never equals the last one, so they can never win, and a snake
    // head on the last cell makes the game unwinnable for everybody.
    private void requireOnBoard(int cell) {
        if (cell < 1 || cell > size) {
            throw new IllegalArgumentException("Cell " + cell + " is off a board of " + size + " cells");
        }
    }

    public int size() {
        return size;
    }

    // Jumps chain, because a ladder can land you on a snake head. Follow them
    // until the cell is quiet. The hop cap turns a badly built board into an
    // error instead of a program that never returns.
    public int destinationFrom(int cell) {
        int current = cell;
        for (int hop = 0; jumps.containsKey(current) && hop <= jumps.size(); hop++) {
            current = jumps.get(current);
        }
        if (jumps.containsKey(current)) {
            throw new IllegalStateException("Jumps loop forever starting at " + cell);
        }
        return current;
    }
}

com.androidinterview.snakeandladder.model.BoardEntity.java

package com.androidinterview.snakeandladder.model;

// One type for both. A snake goes down and a ladder goes up, and that is the
// entire difference between them. Two classes would carry no extra behaviour,
// and they would push a snake branch and a ladder branch into the turn loop
// forever. With one type the loop only ever asks where a cell leads.
public record BoardEntity(int start, int end) {

    public static BoardEntity snake(int head, int tail) {
        if (tail >= head) {
            throw new IllegalArgumentException("A snake has to go down, " + head + " to " + tail);
        }
        return new BoardEntity(head, tail);
    }

    public static BoardEntity ladder(int bottom, int top) {
        if (top <= bottom) {
            throw new IllegalArgumentException("A ladder has to go up, " + bottom + " to " + top);
        }
        return new BoardEntity(bottom, top);
    }
}

com.androidinterview.snakeandladder.model.Dice.java

package com.androidinterview.snakeandladder.model;

import java.util.Random;

// The number of dice is a field, which is the whole reason the two dice
// variant is a one line change rather than a rewrite.
public final class Dice {

    private final int count;
    private final Random random;

    public Dice(int count, Random random) {
        this.count = count;
        this.random = random;
    }

    public int roll() {
        int total = 0;
        for (int die = 0; die < count; die++) {
            total += random.nextInt(6) + 1;
        }
        return total;
    }
}

com.androidinterview.snakeandladder.model.Player.java

package com.androidinterview.snakeandladder.model;

// A name and a cell. Everything else about a turn belongs to the game, because
// a player that knows the rules is a player you edit when the rules change.
public final class Player {

    private final String name;
    private int position;

    public Player(String name) {
        this.name = name;
    }

    public String name() {
        return name;
    }

    public int position() {
        return position;
    }

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

Kotlin

com.androidinterview.snakeandladder.game.Game.kt

package com.androidinterview.snakeandladder.game

import com.androidinterview.snakeandladder.model.Board
import com.androidinterview.snakeandladder.model.Dice
import com.androidinterview.snakeandladder.model.Player

// The whole game. A deque for turn order, one roll, one move, one win check.
// There is no snake handling and no ladder handling here, because the board
// already resolved both into a destination.
//
// The commentary goes to a listener that defaults to println, so the same game
// runs behind a socket, in a test or in a UI without anyone capturing stdout.
class Game(
    private val board: Board,
    private val dice: Dice,
    players: List<Player>,
    private val log: (String) -> Unit = ::println,
) {

    private val turnOrder = ArrayDeque(players)

    var winner: Player? = null
        private set

    val isOver: Boolean get() = winner != null

    fun play() {
        while (!isOver) playTurn()
        log("${winner?.name} wins the game")
    }

    // One player's whole turn. A six earns another roll, and three sixes in a
    // row cancels the turn, which is why the starting cell is kept. The do
    // while condition can read the roll declared inside the body, so there is
    // no flag variable.
    fun playTurn() {
        val player = turnOrder.removeFirst()
        val startedAt = player.position
        var sixes = 0
        do {
            val roll = dice()
            sixes = if (roll == 6) sixes + 1 else 0
            if (sixes == 3) {
                player.moveTo(startedAt)
                break
            }
            move(player, roll)
            if (player.position == board.size) {
                winner = player
                // The winner is not pushed back on the queue. Keeping the rest
                // going until one player is left is what turns this into a
                // ranking, and it costs this one return.
                return
            }
        } while (roll == 6)
        turnOrder.addLast(player)
    }

    private fun move(player: Player, roll: Int) {
        val from = player.position
        val target = from + roll
        // The exact finish rule, and the requirement most people miss. A roll
        // that would overshoot the last cell does not move the player at all.
        val to = if (target > board.size) from else board.destinationFrom(target)
        player.moveTo(to)
        log("${player.name} rolled a $roll and moved from $from to $to")
    }
}

com.androidinterview.snakeandladder.model.Board.kt

package com.androidinterview.snakeandladder.model

// Cell to destination, one map. Two lists would mean scanning both on every
// move, so the entities are flattened into a lookup once and never scanned.
class Board(val size: Int, entities: List<BoardEntity>) {

    private val jumps = entities.associate { it.start to it.end }

    init {
        require(jumps.size == entities.size) { "Two entities start on the same cell" }
        // The board is the only object that knows how big it is, so the range
        // check belongs here rather than on the entity. A ladder to 105 leaves
        // a player on a cell that never equals the last one, so they can never
        // win, and a snake head on the last cell makes the game unwinnable for
        // everybody.
        jumps.forEach { (start, end) ->
            require(start in 1..size && end in 1..size) {
                "$start to $end is off a board of $size cells"
            }
            require(start != size) { "Nothing may start on the last cell, $start" }
        }
    }

    // Jumps chain, because a ladder can drop you on a snake head. Follow them
    // until the cell is quiet. The hop count turns a badly built board into an
    // error rather than a program that never returns.
    tailrec fun destinationFrom(cell: Int, hops: Int = 0): Int {
        check(hops <= jumps.size) { "Jumps loop forever starting at $cell" }
        val next = jumps[cell] ?: return cell
        return destinationFrom(next, hops + 1)
    }
}

com.androidinterview.snakeandladder.model.BoardEntity.kt

package com.androidinterview.snakeandladder.model

// One type for both. A snake goes down and a ladder goes up, and that is the
// entire difference. Two classes would carry no extra behaviour and would push
// a snake branch and a ladder branch into the turn loop forever.
data class BoardEntity(val start: Int, val end: Int)

fun snake(head: Int, tail: Int): BoardEntity {
    require(tail < head) { "A snake has to go down, $head to $tail" }
    return BoardEntity(head, tail)
}

fun ladder(bottom: Int, top: Int): BoardEntity {
    require(top > bottom) { "A ladder has to go up, $bottom to $top" }
    return BoardEntity(bottom, top)
}

com.androidinterview.snakeandladder.model.Dice.kt

package com.androidinterview.snakeandladder.model

import kotlin.random.Random

// A die is only something that produces a number, so it is a function type.
// The count is captured in the lambda, which is what makes the two dice
// variant one line instead of a new class.
typealias Dice = () -> Int

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

com.androidinterview.snakeandladder.model.Player.kt

package com.androidinterview.snakeandladder.model

// A name and a cell. Everything else about a turn belongs to the game, because
// a player that knows the rules is a player you edit when the rules change.
//
// The cell moves through moveTo and nowhere else, so nothing outside the game
// can teleport a player.
class Player(val name: String) {

    var position: Int = 0
        private set

    fun moveTo(cell: Int) {
        position = cell
    }
}

Concurrency and edge cases

There is one real concurrency question and it is about sessions, not about the board. A single game is turn based and single threaded, and adding locks inside it is an answer to a question nobody asked.

If they ask for many games at once, the answer is short. Give each session its own thread. Sessions share nothing except possibly the board, so share the board only if it is immutable, and give each session its own if it is not. Player positions are per session and never touched by another thread. The move commentary goes to a listener that happens to default to printing, so a session running behind a socket hands in its own and nobody has to capture a stream.

The edge cases worth naming.

  • Overshooting the last cell. The player does not move. Say this before they ask.
  • A chain of jumps. Resolve in a loop, not with a single if.
  • A cycle in the chain. The brief usually promises there is none. Cap the hops anyway, so a bad board is an error rather than a hang.
  • Two entities starting on the same cell. Rejected when the board is built, because the map would silently keep one of them.
  • A snake head on the last cell. Makes the game unwinnable, so the board rejects it when it is built, along with any cell outside one to the last.
  • A ladder that ends past the last cell. The player would sit above the finish forever and never win. Same guard, same place.
  • Three sixes in a row. Cancels the turn, and the player returns to where they started the turn, not where they were before the last roll.
  • A six on the winning move. The player has already won. Do not hand them another roll.
  • Two players on the same cell. Perfectly legal here. Sending an opponent home when you land on them is a Ludo rule, and mixing the two up is a real mistake people make.

Watch