Board/Player/Dice/Snake-Ladder-mapping classes, a generic turn-based flow reusable for similar dice games, and multiplayer turn management.
Published September 23, 2026
class Board {
private final int size; // e.g. 100
private final Map<Integer, Integer> jumps = new HashMap<>(); // cell -> destination, for BOTH snakes and ladders
void addSnake(int head, int tail) { jumps.put(head, tail); } // head > tail
void addLadder(int bottom, int top) { jumps.put(bottom, top); } // top > bottom
int resolveLanding(int position) {
return jumps.getOrDefault(position, position); // uniform lookup — caller doesn't need to know snake vs ladder
}
}
class Dice { int roll() { return ThreadLocalRandom.current().nextInt(1, 7); } }
class Player { String name; int position = 0; }
Modeling both snakes and ladders as the same jumps map (rather than two separate structures) is the key simplification: from the board's perspective, both are just "landing on cell X actually means you end up at cell Y" — the distinction between a snake (Y < X) and a ladder (Y > X) is purely cosmetic/narrative, not structurally different, so there's no reason to model them as two different classes or maintain two lookup structures.
class Game {
private final Board board;
private final Dice dice;
private final List<Player> players;
private int currentPlayerIndex = 0;
Player playTurn() {
Player current = players.get(currentPlayerIndex);
int roll = dice.roll();
int newPosition = current.position + roll;
if (newPosition <= board.getSize()) { // overshoot rule: a roll past the final cell doesn't move the player
current.position = board.resolveLanding(newPosition);
}
currentPlayerIndex = (currentPlayerIndex + 1) % players.size();
return current;
}
boolean hasWinner() { return players.stream().anyMatch(p -> p.position == board.getSize()); }
}
This structure — Board, Dice, Player list, a turn-cycling index, and a playTurn()/hasWinner() pair — generalizes directly to other simple dice-based board games (a straightforward racing game with different board rules) with minimal change, precisely because none of the turn-management logic is snake-and-ladder-specific; only Board.resolveLanding()'s jump semantics are.
The currentPlayerIndex modulo cycling is the same pattern used in Tic-Tac-Toe / Board Game Design and Elevator System's request handling — a simple, robust way to cycle through N players without special-casing 2-player vs N-player. hasWinner() checking position == board.getSize() exactly (not >=) directly reflects the overshoot rule already enforced in playTurn() — a roll that would overshoot the final cell is a no-op move, not a win, which is a real rule worth stating explicitly since it's easy to get wrong (some implementations incorrectly clamp to the final cell instead of skipping the move entirely).
A board built from configuration (or randomly generated) can be invalid in ways that break the game. Validate once, at construction, and reject bad boards with a clear message instead of discovering problems mid-game:
final class Board {
private final int size; // e.g. 100
private final Map<Integer, Integer> jumps; // snake: head → tail (down), ladder: bottom → top (up)
Board(int size, Map<Integer, Integer> jumps) {
this.size = size;
this.jumps = Map.copyOf(jumps);
validate();
}
private void validate() {
for (var e : jumps.entrySet()) {
int from = e.getKey(), to = e.getValue();
if (from <= 1 || from >= size) throw new IllegalArgumentException("Jump can't start at " + from);
if (to < 1 || to > size) throw new IllegalArgumentException("Jump from " + from + " leaves the board");
if (from == to) throw new IllegalArgumentException("Jump from " + from + " goes nowhere");
if (jumps.containsKey(to)) throw new IllegalArgumentException(
"Jump from " + from + " lands on another jump start at " + to + " (chains and loops forbidden)");
}
}
int resolve(int square) { return jumps.getOrDefault(square, square); }
int size() { return size; }
}
The rules encoded here:
Different families play by different house rules: an exact roll needed to finish, a bonus turn on a 6, three 6s in a row forfeiting the turn. Keep Game generic, and put the rules in a policy:
interface MoveRules {
int nextPosition(int current, int roll, Board board); // bounce-back, exact finish, etc.
boolean grantsExtraTurn(int roll, int sixesInARow);
}
final class StandardRules implements MoveRules {
public int nextPosition(int current, int roll, Board board) {
int target = current + roll;
if (target > board.size()) return current; // exact roll needed: stay put
return board.resolve(target);
}
public boolean grantsExtraTurn(int roll, int sixesInARow) {
return roll == 6 && sixesInARow < 3; // a third consecutive six ends the turn
}
}
final class Game {
private final Board board; private final Deque<Player> turnOrder; private final Dice dice; private final MoveRules rules;
Game(Board board, List<Player> players, Dice dice, MoveRules rules) {
this.board = board; this.turnOrder = new ArrayDeque<>(players); this.dice = dice; this.rules = rules;
}
Optional<Player> playTurn() {
Player p = turnOrder.pollFirst();
int sixes = 0;
while (true) {
int roll = dice.roll();
sixes = roll == 6 ? sixes + 1 : 0;
p.position = rules.nextPosition(p.position, roll, board);
if (p.position == board.size()) return Optional.of(p); // winner (removed from rotation)
if (!rules.grantsExtraTurn(roll, sixes)) break;
}
turnOrder.addLast(p); // back of the queue
return Optional.empty();
}
}
Deque for turn order makes rotation trivial, and removing finished players (to play on for 2nd and 3rd place) is just a matter of not re-adding them.Dice is an interface. Tests inject a FixedDice(6, 6, 3) to drive exact scenarios (extra turns, bounce-back, landing on a snake), and a game could use two dice, or a loaded die for a variant.@Test void landingOnSnakeHeadMovesPlayerDown() {
Board board = new Board(100, Map.of(17, 7));
Player p = new Player("Asha"); p.position = 14;
Game game = new Game(board, List.of(p), new FixedDice(3), new StandardRules());
game.playTurn();
assertEquals(7, p.position);
}
@Test void overshootingTheLastSquareLeavesPlayerInPlace() {
Player p = new Player("Vikram"); p.position = 98;
new Game(new Board(100, Map.of()), List.of(p), new FixedDice(5), new StandardRules()).playTurn();
assertEquals(98, p.position);
}
Deterministic dice make the randomness a dependency like any other, so every rule can be tested precisely.
Q: Why store jumps as cell-to-destination rather than separate snake/ladder data structures with head/tail semantics? A: The game never needs to know WHY a jump happened, only that landing on cell X means ending at cell Y — collapsing snakes and ladders into one uniform lookup removes an entire unnecessary branch ("is this a snake or ladder?") from every turn's resolution logic.
Q: How would you validate that a snake/ladder configuration doesn't create an infinite loop (e.g., a snake head placed at a ladder's bottom)? A: A validation pass at board-setup time checking that no jump destination is itself a jump source (or more generally, that following the jump chain from any starting cell terminates) — this is worth mentioning as a real edge case the interviewer might probe, even if not fully implemented under time pressure.
Q: How would you extend this design to support a game where players can be sent backward by other mechanisms, not just fixed board cells? A: The resolveLanding() abstraction already generalizes well — a different game's 'go back 3 spaces' card-draw mechanic would just be a different kind of position modifier, computed dynamically rather than looked up from a static jumps map, but plugging into the same 'compute where you actually land after a move' extension point.
Q: Is Dice.roll() being a hard dependency inside Game/Player a design concern? A: Yes — injecting Dice (as an interface) rather than hard-coding random rolling would make the game deterministically testable (a FixedSequenceDice test double returning a scripted sequence of rolls), the same Dependency Inversion argument applied throughout this course to any source of external/random behavior.
Q: Which board configurations should be rejected at construction? A: Jumps starting on the first or last square, jumps that go off the board or nowhere, and jumps that land on another jump's start. The last rule prevents both chained jumps and infinite snake/ladder loops.
Q: How do you test game logic that depends on dice rolls? A: Put the dice behind an interface and inject a scripted implementation that returns a predetermined sequence. Tests can then reproduce exact situations (snakes, ladders, extra turns, exact-finish rules) deterministically.
Q: How would you support different house rules without changing the Game class?
A: Move rule decisions (movement near the end, extra turns, penalties) into a MoveRules strategy. Each variant is a new implementation injected into Game, and the turn loop stays unchanged.