Snake & Ladder: Entity Modeling and a Pluggable Dice
Snakes and ladders are the same move wearing two costumes — a teleport from one cell to another. This chapter walks the textbook design (one unifying Jump concept, a pluggable die), then shows the real solution in this repo, which makes a different, equally defensible set of calls.
3.1Problem Statement
Design the classic Snake & Ladder board game for N players on a 100-cell board. It looks like a children's game, but the interview brief is almost algorithm-free on purpose: there is no pathfinding, no scoring math, nothing clever to compute. The entire grade is your class boundaries and whether you noticed that a "die" is a policy, not a constant.
Functional requirements:
- Configurable board. Snakes (head cell → tail cell) and ladders (bottom cell → top cell) placed at arbitrary positions on a 100-cell board.
- Players roll a die in turn, advance their token by the roll, and their position adjusts automatically if they land on a snake head (slide down) or a ladder bottom (climb up).
- The first player to land exactly on cell 100 wins. You must decide and state your overshoot rule out loud — e.g. a roll that would overshoot cell 100 is forfeited, and the player stays put.
- Support a pluggable die: a single six-sided die, the sum of two dice, or a crooked/loaded die for testing deterministic scenarios.
Non-functional / constraints: almost no algorithm lives here — this problem is purely about clean class boundaries and abstracting the die behind an interface. If your solution has a branch that checks "is this a snake or a ladder," or a `rand()` call sitting inside the game loop, that is the signal you collapsed an abstraction you shouldn't have.
The problem statement hands you the expected shape of the public interface directly, which is itself a hint: lock this down before writing any internals.
class Dice {
public:
virtual int roll() = 0; // SixSidedDice, TwoDice, CrookedDice
virtual ~Dice() = default;
};
struct Jump { // models both snakes and ladders
int start;
int end;
};
class Game {
public:
Game(int boardSize, std::vector<Jump> jumps, std::unique_ptr<Dice> dice);
void addPlayer(const Player& p);
Player startGame(); // runs the simulation loop, returns the winner
};
What the interviewer is really testing:
- Whether you separate the problem into
Board,Jump,Dice,Player, andGame— five nouns, five clear jobs. - Whether the
Diceinterface lets behaviour swap freely without touchingGameat all — the textbook Strategy pattern. - Whether snakes and ladders are unified as one
Jumpconcept (start → end), rather than duplicated into two near-identical classes with no actual behavioural difference.
Follow-up questions to expect:
- Roll again on a 6, with a cap of three consecutive sixes resetting the turn.
- Validate the board at construction: no two jumps share a start cell, no jump starts or ends on cell 1 or cell 100, and no jump cycles.
- Multiple dice and a configurable winning cell — can your design absorb both without new branching?
3.2How to Approach
Read the problem the way an interviewer wrote it: mine the constraints first, because they decide the design, and notice what's conspicuously absent. There's barely any algorithm here, so the whole grade rests on class boundaries and abstractions, not cleverness.
Two phrases in the prompt are doing all the work. "Support a pluggable die" is naming a Strategy pattern outright — a hardcoded rand() % 6 + 1 living inside Game is a fail before you've written a second class. And "snakes (head → tail) and ladders (bottom → top)" describes two nouns that differ only in the direction of the same operation: a teleport from one cell to another. Two separate Snake / Ladder classes would be duplicated code with zero behavioural difference, and interviewers watch for exactly that.
Now circle the nouns: board, cell, snake, ladder, jump, die, player, token, position, game, turn, winner. Collapse the synonyms — snake and ladder both become Jump; token and position both become a plain int on Player — and group what's left by responsibility:
| Class | Owns | Why it exists |
|---|---|---|
Jump (struct) | start, end cells | unifies snake and ladder as one "teleport" |
Dice (interface) | the roll() contract | abstracts how many pips so behaviour swaps freely |
SixSidedDice / TwoDice / CrookedDice | their own RNG/bias | the concrete roll behaviours |
Board | size + start → end jump map | knows geometry; answers "where does this cell send me?" |
Player | id, name, current position | identity + token location |
Game | board, players, dice, turn order | orchestrates the simulation, declares the winner |
Pin the public interface before internals — it forces you to decide what the outside world sees. Dice is an abstract base with one pure-virtual roll(); Game depends on it as an interface and never on a concrete die, so swapping behaviour never touches Game. Game takes a std::unique_ptr<Dice> and owns the injected die explicitly — the die is polymorphic, so it is passed by pointer, never by value, to avoid slicing. startGame() returns the winning Player: a single value out, with the loop running to completion internally.
The key insight is where the unification actually lives: inside Board. A snake and a ladder do the same thing — land on a special cell, get teleported elsewhere — so Board stores one map, start → end, and answers a single question: if a token lands on cell p, where does it actually end up?
std::unordered_map<int, int> jumps_; // start cell -> end cell
// Resolve a landing cell: follow a jump if one starts here, else stay put.
int finalPosition(int landed) const {
auto it = jumps_.find(landed);
return (it != jumps_.end()) ? it->second : landed;
}
Direction is data (end < start is a snake, end > start is a ladder), not type, so one lookup covers both, the simulation loop never special-cases "which feature is this," and the whole resolution is an O(1) map probe. Board owns geometry only — it doesn't know about players, turns, or dice.
With the entities settled, the game loop can stay genuinely thin: roll, bound-check against the overshoot rule, resolve through Board, check for a win, advance the turn.
int target = current.position + roll;
if (target <= board_.size()) { // overshoot rule: skip if it would pass the end
current.position = board_.finalPosition(target);
if (current.position == board_.size()) return current; // winner
}
// else: forfeited move, stay put; turn passes on regardless
All the "what does this cell do" logic lives in Board; all the "how many pips" logic lives in Dice; Game only sequences the two. That division is also what makes the follow-ups land predictably instead of forcing a redesign: roll-again-on-6 is a turn-policy tweak isolated to the loop (track a per-turn six-count, only advance the turn when appropriate); board validation (no shared start cells, nothing anchored to cell 1 or 100, no self-loops) belongs in Board's constructor, so an illegal board can never exist; and multiple dice or a configurable winning cell cost zero new branches — TwoDice is just another Dice subclass, and the winning cell is already the value the loop compares against.
The Design Actually Built: Polymorphism Over a Data Struct
Everything above is the textbook route through this problem, and it's what most interviewers expect you to arrive at. The working solution in this repo (section 3.3) makes a different, equally legitimate set of calls, and it's worth reading both so you can defend either one in an interview.
Instead of one Jump{start, end} struct, the real code gives Snake and Ladder their own classes under an abstract BoardEntity (holding start/end, plus a pure-virtual display() and name()). Board stores them polymorphically in a map<int, BoardEntity*> keyed by start cell — so resolution is still one O(1) lookup, it just returns a base-class pointer instead of a plain int. The trade you're making: two small classes instead of one struct, in exchange for a seam where a snake and a ladder genuinely could behave differently later (different `display()` art, a different sound cue, a "poisonous snake" variant with its own rule) without touching Board at all. The Jump-struct version is leaner when they truly never diverge; the polymorphic version is the safer bet when "never" is doing a lot of work.
The Strategy pattern still shows up — just applied somewhere more interesting than the die. BoardSetupStrategy is the abstraction, with three concrete strategies: RandomBoardStrategy (probabilistic placement scaled by an EASY/MEDIUM/HARD difficulty), CustomCountStrategy (a fixed number of snakes/ladders, either random or hand-placed), and StandardBoardStrategy (the classic hardcoded 100-cell layout). Board::setupBoard(BoardSetupStrategy*) never knows which one it got — the exact same "swap behavior without touching the consumer" property the die was supposed to demonstrate in the textbook design.
Dice class here is concrete, not an interface — there's no SixSidedDice/TwoDice/CrookedDice family, just one class with a faces count, so "pluggable die" from the problem statement isn't actually implemented. And there's no Player or Game class at all: what exists is the board layer (entities + setup strategies + an IObserver notification hook) with no simulation loop wired on top. Section 3.3 adds a small driver on top of the real classes to show them actually playing a game, but making Dice pluggable and adding a proper multi-player Game loop are the two follow-ups this design still owes.3.3Whole Solution
The complete listing below is the real, working code from this repo: an IObserver notification contract, the polymorphic Snake/Ladder board entities, Board itself, and the three BoardSetupStrategy implementations. Since the repo file stops at board setup, a short main() is appended at the end to actually play a game with it — rolling the die, resolving landings through Board::getEntity, and reporting each turn through the notifier — using only the classes already defined above it.
#include <bits/stdc++.h>
using namespace std;
class IObserver{
public:
virtual void update(string msg) = 0;
virtual ~IObserver(){}
};
//Sample Observer Implemetation
class SnakeAndLadderConsoleNotifier : public IObserver{
public:
void update(string msg){
cout << "[Notification] " << msg <<endl;
}
};
class Dice{
private:
int faces;
public:
Dice(int f){
faces = f;
srand(time(0));
}
int roll(){
return (rand()%(this->faces)) +1;
}
};
class BoardEntity{
private:
int startPostion;
int endPostion;
public:
BoardEntity(int start, int end){
startPostion = start;
endPostion = end;
}
int getStart(){
return startPostion;
}
int getEnd(){
return endPostion;
}
virtual void display() = 0;
virtual string name() = 0 ;
virtual ~BoardEntity(){};
};
class Snake : public BoardEntity{
public:
Snake(int start, int end):BoardEntity(start, end){
if(start>=end){
cout<< "Invalid Snake" << endl;
}
}
void display() override{
cout << "Snake: " << getStart() << " -> " << getEnd() << endl;
}
string name() override {
return "SNAKE";
}
};
class Ladder : public BoardEntity{
public:
Ladder(int start, int end):BoardEntity(start, end){
if(start<=end){
cout<< "Invalid Ladder" << endl;
}
}
void display() override{
cout << "Ladder: " << getStart() << " -> " << getEnd() << endl;
}
string name() override {
return "LADDER";
}
};
class BoardSetupStrategy;
class Board{
private:
int size;
vector<BoardEntity*> snakeAndLadder;
map<int, BoardEntity*> boardEntities;
public:
Board(int s){
size = s;
}
bool canAddEntities(int pos){
return boardEntities.find(pos) == boardEntities.end();
}
void addBoardEntities(BoardEntity* boardEntity){
if(canAddEntities(boardEntity->getStart())){
snakeAndLadder.push_back(boardEntity);
boardEntities[boardEntity->getStart()] = boardEntity;
}
}
void setupBoard(BoardSetupStrategy* stategy);
BoardEntity* getEntity(int pos){
if(boardEntities.find(pos) != boardEntities.end()){
return boardEntities[pos];
}
return nullptr;
}
int getBoardSize(){
return size;
}
void display(){
cout<< "\n=== Board Configurations === \n" << endl;
cout << "Board Size: " << size <<" Cells" << endl;
int snakeCount =0 ;
int ladderCount =0 ;
for(auto entity : snakeAndLadder){
if(entity->name()=="SNAKE"){
snakeCount++;
}else{
ladderCount++;
}
}
cout << "\n === Snakes:" <<snakeCount <<endl;
for(auto entity: snakeAndLadder){
if(entity->name()=="Snake"){
entity->display();
}
}
cout << "\n === Ladders:" <<ladderCount <<endl;
for(auto entity: snakeAndLadder){
if(entity->name()=="LADDER"){
entity->display();
}
}
cout << "==============================================" <<endl;
}
~Board(){
for(auto entity:snakeAndLadder){
delete(entity);
}
}
};
class BoardSetupStrategy{
public:
virtual void setupBoard(Board* baord) =0;
virtual ~BoardSetupStrategy(){}
};
class RandomBoardStrategy : public BoardSetupStrategy{
public:
enum Difficulty{
EASY,
MEDIUM,
HARD
};
private:
Difficulty difficulty;
void setupWithProbability(Board* board, double snakeProbability){
int boardSize = board->getBoardSize();
int totalEntities = boardSize/10;
for(int i =0; i<totalEntities; i++){
double prob =(double)rand()/RAND_MAX;
if(prob<snakeProbability){
int attempts = 0;
while(attempts<50){
int start = rand()%(boardSize-10) + 10;
int end = rand()%(start-1)+1;
if(board->canAddEntities(start)){
board->addBoardEntities(new Snake(start, end));
break;
}
attempts++;
}
}else{
int attempts = 0;
while(attempts<50){
int start = rand()%(boardSize-10) + 1;
int end = rand()%(boardSize-start) + start + 1;
if(board->canAddEntities(start) && end<boardSize){
board->addBoardEntities(new Ladder(start, end));
break;
}
attempts++;
}
}
}
}
public:
RandomBoardStrategy(Difficulty d){
difficulty =d;
}
void setupBoard(Board* board) override{
switch(difficulty){
case EASY:
setupWithProbability(board, 0.3);
break;
case MEDIUM:
setupWithProbability(board ,0.5);
break;
case HARD:
setupWithProbability(board, 0.7);
break;
}
}
};
class CustomCountStrategy : public BoardSetupStrategy{
private:
int numLadders;
int numSnakes;
bool randomPostions;
vector<pair<int, int>> snakePostions;
vector<pair<int, int>> ladderPostions;
public:
CustomCountStrategy(int snakes, int ladders, bool random){
numSnakes = snakes;
numLadders = ladders;
randomPostions = random;
}
void addSnakePosition(int start, int end){
snakePostions.push_back(make_pair(start, end));
}
void addLadderPosition(int start, int end){
ladderPostions.push_back(make_pair(start, end));
}
void setupBoard(Board* board)override{
if(randomPostions){
int boardSize = board->getBoardSize();
int snakesAdded= 0 ;
while(snakesAdded<numSnakes){
int start = rand()%(boardSize-10)+10;
int end = rand()%(start-1)+1;
if(board->canAddEntities(start)){
board->addBoardEntities(new Snake(start, end));
snakesAdded++;
}
}
int laddersAdded= 0 ;
while(laddersAdded<numLadders){
int start = rand()%(boardSize-10)+ 1 ;
int end = rand()%(boardSize-start)+start+1;
if(board->canAddEntities(start)){
board->addBoardEntities(new Ladder(start, end));
laddersAdded++;
}
}
}else{
for(auto pos : snakePostions){
if(board->canAddEntities(pos.first)){
board->addBoardEntities(new Snake(pos.first, pos.second));
}
}
for(auto pos : ladderPostions){
if(board->canAddEntities(pos.first)){
board->addBoardEntities(new Ladder(pos.first, pos.second));
}
}
}
}
};
class StandardBoardStrategy : public BoardSetupStrategy{
public:
void setupBoard(Board* board){
if(board->getBoardSize()!=100){
cout<<"Standard Stretegy Only for 10x10" <<endl;
return;
}
board->addBoardEntities(new Snake(99,54));
board->addBoardEntities(new Snake(95,75));
board->addBoardEntities(new Snake(92,88));
board->addBoardEntities(new Snake(89,68));
board->addBoardEntities(new Snake(74,53));
board->addBoardEntities(new Snake(64,60));
board->addBoardEntities(new Snake(62,19));
board->addBoardEntities(new Snake(49,11));
board->addBoardEntities(new Snake(46,25));
board->addBoardEntities(new Snake(16,6));
board->addBoardEntities(new Ladder(2,38));
board->addBoardEntities(new Ladder(7,14));
board->addBoardEntities(new Ladder(8,31));
board->addBoardEntities(new Ladder(15,26));
board->addBoardEntities(new Ladder(21,42));
board->addBoardEntities(new Ladder(28,84));
board->addBoardEntities(new Ladder(36,44));
board->addBoardEntities(new Ladder(51,67));
board->addBoardEntities(new Ladder(71,91));
board->addBoardEntities(new Ladder(78,98));
}
};
void Board::setupBoard(BoardSetupStrategy* strategy){
strategy->setupBoard(this);
}
// --- driver: plays one game using only the classes above ---
int main(){
Board board(100);
StandardBoardStrategy standard;
board.setupBoard(&standard);
board.display();
Dice dice(6);
SnakeAndLadderConsoleNotifier notifier;
int position = 1;
int turn = 1;
while (position < board.getBoardSize()) {
int roll = dice.roll();
int target = position + roll;
if (target > board.getBoardSize()) {
notifier.update("Turn " + to_string(turn) + ": rolled " + to_string(roll) + ", overshoot, staying at " + to_string(position));
} else {
position = target;
BoardEntity* entity = board.getEntity(position);
if (entity) {
notifier.update("Turn " + to_string(turn) + ": landed on " + to_string(position) + ", hit a " + entity->name() + ", moved to " + to_string(entity->getEnd()));
position = entity->getEnd();
} else {
notifier.update("Turn " + to_string(turn) + ": moved to " + to_string(position));
}
}
turn++;
}
notifier.update("Reached cell " + to_string(position) + " - game over!");
return 0;
}
Why unify snakes and ladders into one Jump concept instead of two classes?
Because they have zero behavioural difference — both are "land on cell X, get moved to cell Y." The only thing that differs is the direction of the move, and direction is a fact about the two integers (end < start vs. end > start), not a reason to fork the type hierarchy. A single start → end map lets Board answer "where does this cell send me?" with one O(1) lookup, and it keeps the simulation loop from ever having to ask "is this a snake or a ladder" before deciding what to do.
How would you support "roll again on a 6," capped at three consecutive sixes?
This is a turn-policy rule, so it belongs entirely inside the game loop, not inside Board or Dice. Track a per-turn six-count: on a roll of 6, increment it and let the same player roll again, unless the count reaches three, in which case the whole turn is forfeited and play passes on. Any roll that isn't a 6 resets the counter and advances the turn normally. Neither the board's jump map nor the die implementation needs to change at all.
Where would you add board validation, and what would you check?
In Board's constructor, so an invalid board can never exist in the first place. Check that no two jumps share a start cell (ambiguous landing), that no jump starts or ends on cell 1 or the final cell, and that no jump is a self-loop. Whether a jump's destination can itself be another jump's start (chained snakes and ladders) is a rule you should state explicitly rather than assume — most implementations resolve exactly one jump per landing and treat chaining as disallowed.
The actual code uses polymorphic Snake/Ladder classes and puts Strategy on board setup, not the die. Is that a worse design?
Not worse — different trade-offs, and both are defensible in an interview. A Jump struct is leaner when snake and ladder behavior truly never diverges; polymorphic Snake/Ladder classes cost one extra type but leave a seam for them to diverge later (different rendering, a rule variant) without ever touching Board. Putting Strategy on who builds the board rather than how the die rolls is arguably the more realistic call: production Snake & Ladder implementations vary far more in how boards get generated (random, curated, difficulty-scaled) than in how a die works, and this repo's version demonstrates the pattern on the axis that actually needs to vary. The one real gap is that the die itself stayed a concrete class, so "pluggable die" from the brief is still unaddressed — wrapping it behind the same Dice interface from 3.1 is a small, isolated change.