Why this matters: this implementation is short — under a hundred and fifty lines — which means every line is visible to the interviewer and every misplaced rule shows. The walkthrough below builds the vocabulary types first, then the board, then the loop, narrating at each step what the code is promising.
The board: constructed valid, resolved in one place
class Board {
private final int size; // final cell = size
private final Map<Integer, Integer> jumps; // snakes AND ladders
Board(int size, Map<Integer, Integer> jumps) {
validate(size, jumps); // throws on bad config
this.size = size;
this.jumps = Map.copyOf(jumps);
}
private static void validate(int size, Map<Integer, Integer> jumps) {
for (var e : jumps.entrySet()) {
int from = e.getKey(), to = e.getValue();
if (from <= 1 || from >= size || to <= 1 || to >= size)
throw new IllegalArgumentException(
"jump touches cell 1 or final cell: " + from + "->" + to);
if (from == to)
throw new IllegalArgumentException("jump to itself: " + from);
}
// duplicate starts are impossible in a Map — say so, don't code it
}
int resolve(int cell) {
int hops = 0;
while (jumps.containsKey(cell)) {
cell = jumps.get(cell);
if (++hops > jumps.size()) // defensive: cycle guard
throw new IllegalStateException("jump cycle at " + cell);
}
return cell;
}
int size() { return size; }
}Three things to narrate. First, snakes and ladders are one map — the direction of the pair is the only difference, so the mechanism is shared. Second, the constructor is the validator: a Board that exists is a Board whose invariants hold, and note the free win worth saying aloud — using a Map makes duplicate start cells unrepresentable, so that rule needs no code at all. Third, resolve is the chain rule from lesson 01 in six lines, with the cycle guard converting "impossible" into a crisp error rather than a hang.
The movement rule: overshoot as a strategy
interface MovementRule {
// Returns the target cell, or empty if the move doesn't happen.
OptionalInt target(int position, int roll, int boardSize);
}
class StayPutOnOvershoot implements MovementRule {
public OptionalInt target(int position, int roll, int boardSize) {
int t = position + roll;
return t > boardSize ? OptionalInt.empty() : OptionalInt.of(t);
}
}OptionalInt.empty() is the stay-put rule — no move to make. A bounce-back variant would be a second class and a one-line change at construction time; nothing else in the program knows overshoot exists.
The die: injectable by design
interface Die { int roll(); }
class RandomDie implements Die {
private final Random random = new Random();
public int roll() { return random.nextInt(6) + 1; }
}In tests, Die is a scripted sequence — which is how you'll deterministically drive a player onto a chain or an exact win. Mention that while writing it; the interface's reason for existing is the test you haven't written yet.
The game: orchestration that reads like the rules
class Player {
final String name;
int position = 0; // off-board start
Player(String name) { this.name = name; }
}
class Game {
private final Board board;
private final MovementRule rule;
private final Die die;
private final List<Player> players; // fixed turn order
private int current = 0;
private Player winner = null;
Game(Board board, MovementRule rule, Die die, List<Player> players) {
if (players.size() < 2 || players.size() > 6)
throw new IllegalArgumentException("2-6 players");
this.board = board; this.rule = rule; this.die = die;
this.players = List.copyOf(players);
}
void playTurn() {
if (winner != null) return;
Player p = players.get(current);
int roll = die.roll();
rule.target(p.position, roll, board.size())
.ifPresent(t -> p.position = board.resolve(t));
if (p.position == board.size()) winner = p;
else current = (current + 1) % players.size();
}
Optional<Player> winner() { return Optional.ofNullable(winner); }
}playTurn is the whole game and contains no rules: the movement rule decides whether and where, the board decides what landing there means, and the loop just sequences. Walk an interviewer down those four lines slowly — roll, target, resolve, win-check — and point at the invariant they maintain: p.position is only ever assigned a resolved cell, so no player is ever stored mid-jump.
One deliberate subtlety: the turn counter doesn't advance on a win. The game freezes with the winner at the final cell — a small choice, but voicing it shows you thought about the terminal state rather than letting it fall out accidentally.
Key takeaway
The code keeps each rule where the design placed it: the board constructs-or-throws and resolves chains in one guarded loop, overshoot lives in a strategy whose empty result is the stay-put rule, the die is an interface because the tests need a script, and playTurn is four lines of pure sequencing that only ever store resolved positions. When the interviewer asks "what if the rule changed?", every answer is "one class, and the loop's diff is empty."