Beginner22 min readMachine Coding Practicelive prototype

Snake & Ladder

A board game with no strategy at all — which is exactly what makes it a design problem. There is nothing to be clever about, so the interviewer gets a clean look at how you model a turn loop, a jump table, and the thing candidates almost always get wrong: randomness you can test.

The idea

What it is

“Design snakes and ladders.” There is no skill in this game. You roll, you move, you climb or you slide. A five-year-old plays it correctly. So what is there to design?

Exactly that. Because the rules are trivial, nothing hides behind them — the interviewer sees your modelling with no algorithm to distract from it. Three things get judged: how you represent the board, how you run the turn loop, and whether the randomness is something you can control in a test. Most candidates nail the first two and fail the third without noticing.

Board — 100 squares in ONE line 10099 98 91 🏁 8182 90 4039 36🐍 21 30 1 4🪜 10 🔴 Square Jump 36 → 6 Token 4 Dice an interface, not Math.random() ← the whole point
Five nouns, and the last one is the exam question. Everything else in this problem is a lookup and a loop.

The whole system in three sentences

The board is an integer from 1 to 100 — the grid is only how it is drawn. A jump map turns any landing square into a destination, and snakes and ladders are the same map. A turn loop takes the next player from a queue, asks the dice for a number, moves, and stops when someone reaches 100.

The three things being graded

  1. Is the board a line? Modelling it as a 10×10 grid means converting coordinates on every move for no benefit. A player's position is one number.
  2. Are snakes and ladders one thing? They are both “landing here sends you there”. Two classes with identical fields and opposite comparisons is duplicate code with a costume on.
  3. Can you test it? If Math.random() is called inside playTurn(), you cannot write a single deterministic test of your own game. This is the one that separates candidates.

Mechanics

How it works

Step 1 · Clarify — 3 minutes

  • How many players? — build a queue and it does not matter. Two, three, six: same code.
  • Do you need an exact roll to land on 100? — this is a real house rule and it splits opinion. Ask, then make it a flag, because they will ask you to change it.
  • Does a six give another turn? — another common house rule. Same answer: a rule, not a rewrite.
  • Can two tokens share a square? — usually yes. If they say “no, you send the other player back to 1”, that is a genuinely different game and worth 30 seconds of scoping.
  • Where do snakes and ladders come from? — configuration. A board with hardcoded jumps cannot be tested or varied.

The question that is really a design decision

“Should the game print the board?” If your Game class contains System.out.println, you cannot run it in a UI, a test, or a server. Keep the game silent and let the caller render — a two-second decision that keeps the whole design usable.

Step 2 · The board is a line

The physical board snakes back and forth — 1 to 10 left to right, then 11 to 20 right to left. That layout is a rendering detail. To the game, square 47 is just 47, and moving is position + roll.

HOW IT IS DRAWN — a grid, rows alternating direction 21 → 22 → 23 → 24 → 25 → 26 → 27 → 28 → 29 → 30 20 ← 19 ← 18 ← 17 ← 16 ← 15 ← 14 ← 13 ← 12 ← 11 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 rendering only — the game never needs a row or a column WHAT THE GAME STORES — one integer 1 25 50 75 100 🔴 position + roll = new position
If your Player has a row and a col, you have imported the drawing into the rules. One int position is the whole board.

Step 3 · Snakes and ladders are the same thing

A snake takes you from 36 down to 6. A ladder takes you from 4 up to 14. Both are “if you land on from, go to to. The only difference is the sign, and the sign is data.

✗ TWO CLASSES, TWO LOOPS class Snake { head, tail } class Ladder { bottom, top } for (s : snakes) if (s.head == pos) pos = s.tail; for (l : ladders) if (l.bottom == pos) pos = l.top; two identical shapes · O(n) per move and a new entity for every variant ✓ ONE MAP Map<Integer, Integer> jumps jumps.put(4, 14);🪜 up jumps.put(36, 6);🐍 down pos = jumps.getOrDefault(pos, pos); one line · O(1) per move to < from → snake to > from → ladder
The prototype prints this lookup as you play: land on 4 and the explain line reads jumps[4] = 14. One map, two behaviours, and the difference is arithmetic rather than inheritance.

Should a jump chain?

Land on a ladder that ends on a snake's head — do you slide again? Real rules say no: one jump per turn. Say this out loud and make it a single if rather than a while. It is a two-word answer that shows you thought about the edge, and it is a genuinely ambiguous rule so the interviewer will not have a fixed answer either.

Step 4 · The dice — the part everybody gets wrong

Here is the line most candidates write, and it quietly ruins the design:

the line that kills testability
public void playTurn() {
    int roll = 1 + new Random().nextInt(6);      // <-- randomness welded into the game
    ...
}

The game now cannot be tested. You cannot check “a player at 4 climbs to 14”, because you cannot make the dice show a 4. You cannot check “from 97 a roll of 5 forfeits the turn”. You cannot reproduce a reported bug. Every test you write is a coin toss.

Game holds a «interface» Dice + roll() : int RandomDice for playing LoadedDice([4,6,2,…]) for testing ✗ Math.random() inside playTurn: no test can pin a value, no bug can be replayed ✓ dice injected: “from 4, roll 4 → lands 8” is a one-line test, and a failing game replays exactly
This is Dependency Inversion (DIP) on the smallest possible surface — one method, one interface — and it is the highest-value four lines in the whole problem.
what it buys you
interface Dice { int roll(); }

class RandomDice implements Dice {
    private final Random random = new Random();
    public int roll() { return 1 + random.nextInt(6); }
}

class LoadedDice implements Dice {              // for tests, replays and demos
    private final int[] values;
    private int i = 0;
    LoadedDice(int... values) { this.values = values; }
    public int roll() { return values[i++ % values.length]; }
}

// now this is a real, deterministic test:
Game game = new Game(board, List.of(asha), new LoadedDice(4));
game.playTurn();
assertEquals(14, asha.position());               // 4 is a ladder to 14

Try it in the prototype

Switch to 🎯 Loaded, press ↺ New game, roll three times, then reset and roll three times again. Identical game, both times. That reproducibility is the only reason the dice is an object — and it is worth saying exactly that when the interviewer asks why you bothered.

Step 5 · The turn loop

queue.poll() dice.roll() target = pos + roll target > 100 && exactLanding → forfeit pos = target apply jump pos == 100 ? winner — stop otherwise queue.offer(player) — back of the line, and the next turn begins
The queue is the whole turn manager: poll from the front, offer to the back. Three players or six, the code is identical — and a player who wins is simply never re-queued.

Do not put the loop inside Game

while (true) { playTurn(); } inside the game means it can only ever run as a console program. Expose playTurn() and let the caller loop — that is what makes the same class work in a main(), in a test, and behind a UI button. The prototype above calls exactly this method once per click.

House rules are configuration, not code

HOUSE RULE HOW IT IS EXPRESSED exact roll needed to land on 100 boolean exactLanding rolling a 6 grants another turn boolean extraTurnOnSix a jump can land you on another jump if → while, behind a flag a different board (size, snakes, ladders) passed into the constructor
None of these is a new class. Toggle exact landing in the prototype and the same code plays a different game — that is what “rules as data” buys you.

When flags become too many

Three or four booleans is fine and honest. Past that, bundle them into a GameRules object you pass in — one parameter instead of six, and named presets (GameRules.classic(), GameRules.quick()) become possible. Mention the threshold rather than pre-building it; see Pattern overuse & anti-patterns.

The class diagram

Game - turns : Queue<Player> + playTurn() : TurnResult no loop, no printing Board - jumps : Map<int,int> - lastSquare : int 2..* Player - name : String - position : int GameRules - exactLanding - extraTurnOnSix rolls «interface» Dice + roll() : int RandomDice LoadedDice a Player's whole state is one integer — the board is a line, so there is nothing else to store
Five classes and one interface. The interface is Dice, and if you only get one abstraction into this design, make it that one. Notation: Class diagrams.

The follow-ups

  • “Write a test that a player climbs a ladder.” → already free. new LoadedDice(4), one turn, assert position 14. If you welded in Math.random(), you are now rewriting the design under time pressure.
  • “Simulate 10,000 games and report the average number of turns.” → the reason playTurn() does not print and does not loop. The caller loops; the game stays silent. This follow-up is common and it punishes a chatty Game class.
  • “Rolling a six gives another turn.” → do not re-queue the player when roll == 6. One line, because the queue is the turn order.
  • “Landing on an occupied square sends the other player back to 1.” → the only follow-up that genuinely changes the model: the game now needs to look up who is on a square, so positions become a two-way relationship.
  • “Make it playable over a network.”playTurn() is already a request handler. Add validation that the caller is the current player, and make it atomic — same shape as the check-and-take in Parking Lot.
  • “Log every move for replay.” → keep a move history, exactly as in Tic-Tac-Toe. Replay a game by feeding the recorded rolls into a LoadedDice.

How this round is lost

  • Math.random() inside the turn. The single most common miss, and the one that makes every follow-up harder.
  • Separate Snake and Ladder classes. Two identical shapes and two loops where one map would do.
  • A 2D board. Row/column conversions on every move, in service of a drawing the game never needs.
  • while (true) and println inside Game. Now it cannot be tested, simulated, or put behind a UI.
  • Forgetting the overshoot rule entirely. A player sitting on 99 rolls a 4 and lands on 103 — and your array index throws.

Interactive prototype

See it. Build it. Break it.

A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.

About this simulation

A real three-player game. Press 🎲 Roll and the token walks square by square — because the board is a line, not a grid — then a 🐍 or a 🪜 may move it again in a second beat. Every jump is the same one-line lookup; the explain panel shows it as jumps[4] = 14. Now the part that matters: switch the dice to 🎯 Loaded, press ↺ New game, and roll. Do it again. The same game happens every time — because the dice is an object you can swap, not a call to Math.random() buried inside the turn. Then flip exact landing off and watch a house rule change with no code change.

Hands-on

Try these yourself

Open the prototype above, predict what happens, then verify.

try 01

Watch a move happen in two beats

Press 🎲 Roll. The token walks one square at a time to the target — that is position + roll on a line. Then, if it landed on a 🐍 or 🪜, a second beat fires and the explain line shows the actual lookup, jumps[4] = 14. Two beats, because a move and a jump are two different things.

try 02

Prove the game is deterministic

Switch to 🎯 Loaded, press ↺ New game, and roll three times — note where everyone lands. Reset and do it again. Identical. Now switch back to 🎲 Random and repeat: never the same twice. That difference is the entire argument for making the dice an object.

try 03

Test a specific rule by hand

With the loaded dice, the first roll is always a 4 — and square 4 is a ladder to 14. That is exactly the unit test in the Java sample: new LoadedDice(4), one turn, assert 14. You just ran it manually; in code it is three lines and takes a millisecond.

try 04

Flip a house rule

Play until someone is in the 90s, then toggle between ✓ Required and ✗ Overshoot wins. Under the strict rule a player on 97 needs exactly a 3 and forfeits on anything higher; under the loose rule any roll of 3 or more finishes it. Same code, one boolean.

try 05

Let it run

Press ⏩ Play 10 turns and just watch the turn order rotate: 🔴 → 🔵 → 🟢 → 🔴. That rotation is one queue with poll() and offer(). Adding a fourth player would not change a single line of the turn logic.

try 06

Build it from memory

Blank file, in this order: Dice interface with RandomDice and LoadedDice first (it is the design decision, so write it first) → Board with the jump map → Player with one int → Game.playTurn() → a main() that loops until someone wins. Then write the ladder test. If the test needs more than three lines, your dice is not injected properly.

In practice

When to use it — and what trips people up

Injecting randomness — the transferable lesson

Everything in this problem is easy except one habit, and that habit is worth more than the game. Anything non-deterministic that your logic depends on should arrive from outside, behind a tiny interface you can swap.

  • The clock. Instant.now() inside a method is the same bug as Math.random() inside a turn. Pass the time in — exactly what ATM and Parking Lot both do with exitAt.
  • Random values — dice, shuffles, sampling, jitter, IDs. Inject a source, or at minimum inject the seed.
  • UUIDs and IDs. A generator interface makes assertions on created objects possible.
  • The network and the filesystem. Same principle at a bigger scale: a BankService interface with a FakeBank is why ATM can demo a rollback.

The cheap version, when you have no time

Even without an interface, taking a seeded Random in the constructor makes the whole game reproducible: new Game(board, players, new Random(42)). It is one parameter and it recovers most of the benefit. Worth knowing as the fallback — but the interface is better and costs four lines.

Where this design stops

  • Multiplayer over a network. playTurn() becomes a request handler and needs to verify who is calling and be atomic. The design supports it; it just does not do it yet.
  • Games with actual choices. Ludo lets you pick which token to move, so a turn takes a parameter and Player becomes a strategy. That is the natural next step up from this problem.
  • Interaction between tokens. The moment landing on an occupied square does something, positions stop being independent and the board needs to answer “who is on square 47?”

If you only remember one thing

Never call the random number generator, or the clock, from inside your logic. Take them as collaborators. It is four lines here and it is the difference between a system you can test and one you can only run.

What it gives you

  • The dice behind an interface makes the entire game deterministic on demand — tests, replays and bug reproduction all become trivial.
  • One jump map instead of Snake and Ladder classes makes lookups O(1) and removes a whole duplicated code path.
  • Position as a single integer means no coordinate maths, and the board layout stays a rendering concern.
  • playTurn() returning a result instead of looping and printing lets the same class run in a test, a UI, or a 10,000-game simulation.
  • House rules as booleans mean the common variants cost nothing, and the board itself is passed in rather than hardcoded.

Common mistakes

  • The design assumes a turn has no choices; games where a player picks a move (Ludo, backgammon) need playTurn to take a parameter and Player to become a strategy.
  • Positions are independent, so any rule about tokens interacting requires a new square-to-player lookup the board does not currently have.
  • GameRules as loose booleans stops scaling past three or four flags before it wants to become a real object with presets.
  • One jump per turn is hardcoded as a design choice — chaining jumps is a genuinely ambiguous rule that this model quietly decides for you.
  • There is no move history, so replay and undo need to be added rather than falling out of the design as they do in tic-tac-toe.

Reference

Code & further reading

A minimal reference implementation and pointers worth bookmarking.

import java.util.*;

// ---------- the design decision: randomness is a collaborator, not a call ----------
interface Dice {
    int roll();
}

class RandomDice implements Dice {
    private final Random random;
    private final int faces;
    RandomDice()                     { this(6, new Random()); }
    RandomDice(int faces, Random r)  { this.faces = faces; this.random = r; }
    public int roll() { return 1 + random.nextInt(faces); }
}

/** Fixed sequence — for tests, replays and reproducible demos. */
class LoadedDice implements Dice {
    private final int[] values;
    private int i = 0;
    LoadedDice(int... values) { this.values = values; }
    public int roll() { return values[i++ % values.length]; }
}

class Player {
    private final String name;
    private int position = 0;                 // 0 == not yet on the board
    Player(String name) { this.name = name; }
    String name()      { return name; }
    int position()     { return position; }
    void moveTo(int p) { this.position = p; }
}

/** A snake and a ladder are the same thing: land on "from", go to "to". */
class Board {
    private final int lastSquare;
    private final Map<Integer, Integer> jumps;

    Board(int lastSquare, Map<Integer, Integer> jumps) {
        this.lastSquare = lastSquare;
        this.jumps = Map.copyOf(jumps);
        jumps.forEach((from, to) -> {
            if (from < 1 || from > lastSquare || to < 1 || to > lastSquare)
                throw new IllegalArgumentException("jump off the board: " + from + "->" + to);
            if (from.equals(to)) throw new IllegalArgumentException("jump to itself: " + from);
        });
    }

    int lastSquare() { return lastSquare; }

    /** One lookup. to &lt; from is a snake, to &gt; from is a ladder — the difference is arithmetic. */
    int destinationFrom(int square) { return jumps.getOrDefault(square, square); }

    static Board classic() {
        Map<Integer, Integer> j = new HashMap<>();
        j.put(1, 38);  j.put(4, 14);  j.put(9, 31);  j.put(21, 42);
        j.put(28, 84); j.put(51, 67); j.put(71, 91); j.put(80, 100);   // ladders
        j.put(17, 7);  j.put(36, 6);  j.put(49, 11); j.put(54, 34);
        j.put(62, 18); j.put(87, 24); j.put(95, 56); j.put(98, 78);    // snakes
        return new Board(100, j);
    }
}

/** House rules are data, not subclasses. */
record GameRules(boolean exactLanding, boolean extraTurnOnSix) {
    static GameRules classic() { return new GameRules(true, true); }
}

record TurnResult(Player player, int roll, int from, int to, boolean jumped, boolean won, boolean forfeited) {}

class Game {
    private final Board board;
    private final Deque<Player> turns = new ArrayDeque<>();
    private final Dice dice;
    private final GameRules rules;
    private Player winner;

    Game(Board board, List<Player> players, Dice dice, GameRules rules) {
        if (players.size() < 2) throw new IllegalArgumentException("need at least two players");
        this.board = board;
        this.dice = dice;
        this.rules = rules;
        this.turns.addAll(players);
    }

    boolean isOver()  { return winner != null; }
    Player winner()   { return winner; }
    Player current()  { return turns.peekFirst(); }

    /** ONE turn. No loop, no printing — so this works in a test, a UI or a simulation. */
    TurnResult playTurn() {
        if (isOver()) throw new IllegalStateException("game is over");

        Player player = turns.removeFirst();
        int roll = dice.roll();
        int from = player.position();
        int target = from + roll;

        // overshoot: forfeit, or clamp, depending on the house rule
        if (target > board.lastSquare()) {
            if (rules.exactLanding()) {
                turns.addLast(player);
                return new TurnResult(player, roll, from, from, false, false, true);
            }
            target = board.lastSquare();
        }

        int after = board.destinationFrom(target);      // one jump per turn, never a chain
        player.moveTo(after);

        if (after == board.lastSquare()) {
            winner = player;
            return new TurnResult(player, roll, from, after, after != target, true, false);
        }

        // rolling a six keeps you at the front of the queue
        if (rules.extraTurnOnSix() && roll == 6) turns.addFirst(player);
        else                                     turns.addLast(player);

        return new TurnResult(player, roll, from, after, after != target, false, false);
    }
}

public class Main {
    public static void main(String[] args) {
        List<Player> players = List.of(new Player("Asha"), new Player("Ravi"), new Player("Meera"));

        // ---- deterministic: the same game every run ----
        Game game = new Game(Board.classic(), players, new LoadedDice(4, 6, 2, 5, 3, 1), GameRules.classic());
        while (!game.isOver()) {
            TurnResult t = game.playTurn();
            System.out.printf("%-6s rolls %d : %d -> %d%s%s%n",
                    t.player().name(), t.roll(), t.from(), t.to(),
                    t.jumped() ? "  (jump)" : "", t.forfeited() ? "  (forfeit)" : "");
        }
        System.out.println("winner: " + game.winner().name());

        // ---- the test the injected dice makes possible ----
        Player solo = new Player("Test");
        Game t = new Game(Board.classic(), List.of(solo, new Player("Other")),
                          new LoadedDice(4), GameRules.classic());
        t.playTurn();
        System.out.println("rolled 4 from 0, ladder at 4 -> position " + solo.position() + " (expected 14)");
    }
}

References & further reading

6 sources

Knowledge check

Did it land?

Quick questions, answers revealed on submit. Sign in to save your best score.

question 01 / 08

Why should the dice be an interface rather than a call to the random number generator inside playTurn()?

question 02 / 08

How should snakes and ladders be represented?

question 03 / 08

A player on 97 rolls a 5 under the “exact landing required” rule. What happens?

question 04 / 08

Why store a player's position as a single integer rather than a row and a column?

question 05 / 08

Why should playTurn() play exactly one turn instead of the game containing while (true)?

question 06 / 08

How is turn order best managed for three or more players?

question 07 / 08

A ladder ends on a square that is a snake's head. What should happen, and how should you answer?

question 08 / 08

You have five minutes left and no Dice interface yet. What is the best fallback?

0/8 answered