LLD Cohort · Week 3 · Sun 29 Mar 2026 · recording 3h13m

Prototype, Decorator & Snake and Ladder

A quick recap of Builder and the Factory family, then the last creational pattern (Prototype), the first structural pattern (Decorator), and a first full LLD walk-through: Snake and Ladder.

Each topic: one-line idea → the problem → real-life picture → the design step by step → Python code → doubts & corrections → remember

Recap: Builder, Simple Factory, Factory Method, Abstract Factory ▶ 1:20:00 – 1:34:00

All four are creational patterns: they control how objects get made, so the code that uses objects does not have to care.

Builder: fixes "constructor explosion"

A constructor explosion happens when a class has a few mandatory fields and many optional ones (a Student with name, age, id, address, email...). You end up writing a constructor for every combination. Builder fixes it:

Python
class Student:
    def __init__(self, builder: "Student.Builder"):
        # copy everything the builder collected into the real object
        self.name = builder.name
        self.age = builder.age
        self.address = builder.address
        self.email = builder.email

    def __repr__(self) -> str:
        return f"Student({self.name}, {self.age}, {self.address}, {self.email})"

    class Builder:                       # "static inner class" in Java
        def __init__(self, name: str):   # mandatory field goes in the constructor
            self.name = name
            self.age = None              # optional fields start empty
            self.address = None
            self.email = None

        def set_age(self, age: int) -> "Student.Builder":
            self.age = age
            return self                  # return self -> calls can be chained

        def set_address(self, address: str) -> "Student.Builder":
            self.address = address
            return self

        def set_email(self, email: str) -> "Student.Builder":
            self.email = email
            return self

        def build(self) -> "Student":
            return Student(self)


# usage
s = Student.Builder("Ravi").set_address("Pune").set_email("r@x.com").build()
print(s)   # Student(Ravi, None, Pune, r@x.com)
Java vs Python

Java needs Builder because it has no default or keyword arguments. In Python, Student(name, age=None, email=None) already avoids the explosion. Builder still helps in Python when building is step-by-step, needs checks before the object exists, or the object should be immutable after build().

Factory family: keep object creation away from the client

The client (the code that uses the object) should only ask for an object, not know how it is built. If the business code writes WhatsAppNotification() directly, it knows too much about concrete classes and must change whenever they change.

VariantHow it worksWeak spot
Simple FactoryOne NotificationFactory.get_notification(type) with if/else that returns SMS / WhatsApp / Email.Adding Slack means editing the if/else, which breaks the Open-Closed Principle (OCP: open for extension, closed for modification).
Factory MethodOne factory class per product: SMSNotificationFactory, EmailNotificationFactory, all behind a NotificationFactory interface.A new product means a new product class and a new factory class, but no existing code changes.
Abstract FactoryA "factory of factories". One factory makes a whole family of related objects: PaytmFactory makes Paytm payment + refund + invoice; RazorpayFactory makes the Razorpay set.Many classes. Adding a new kind of product (say, Dispute) touches every factory.
Python
from abc import ABC, abstractmethod


# ---- Simple factory: one class decides with if/elif ----
class Notification(ABC):
    @abstractmethod
    def send(self, msg: str) -> None: ...

class SMSNotification(Notification):
    def send(self, msg: str) -> None: print(f"SMS: {msg}")

class WhatsAppNotification(Notification):
    def send(self, msg: str) -> None: print(f"WhatsApp: {msg}")

class NotificationFactory:
    @staticmethod
    def get_notification(kind: str) -> Notification:
        if kind == "SMS":
            return SMSNotification()
        if kind == "WHATSAPP":
            return WhatsAppNotification()
        raise ValueError(kind)           # adding "SLACK" means editing here -> breaks OCP


# ---- Abstract factory: one factory builds a whole FAMILY ----
class PaymentGatewayFactory(ABC):
    @abstractmethod
    def create_payment(self) -> str: ...
    @abstractmethod
    def create_refund(self) -> str: ...
    @abstractmethod
    def create_invoice(self) -> str: ...

class PaytmFactory(PaymentGatewayFactory):
    def create_payment(self) -> str: return "PaytmPayment"
    def create_refund(self) -> str: return "PaytmRefund"
    def create_invoice(self) -> str: return "PaytmInvoice"

class RazorpayFactory(PaymentGatewayFactory):
    def create_payment(self) -> str: return "RazorpayPayment"
    def create_refund(self) -> str: return "RazorpayRefund"
    def create_invoice(self) -> str: return "RazorpayInvoice"


# usage
NotificationFactory.get_notification("SMS").send("Hi")        # SMS: Hi
factory: PaymentGatewayFactory = RazorpayFactory()            # swap ONE line to change family
print(factory.create_payment(), factory.create_invoice())     # RazorpayPayment RazorpayInvoice
Real-life picture

Abstract Factory is a car company's "electric" factory versus "petrol" factory: ask the electric factory and every vehicle you get (car, truck, bus) is electric. Same idea as the UI theme example: LightThemeFactory gives a light button and a light checkbox; DarkThemeFactory gives the dark pair.

Doubt: In interviews they ask "factory method vs abstract factory". What is the answer?
→

Factory Method creates one product per factory. Abstract Factory creates a group (family) of related products that must match, like Paytm payment + Paytm refund + Paytm invoice.

Doubt: My class had 9 fields, all mandatory, and SonarQube complained (limit 7). I used a Builder. Is that right?
→

Yes, that is a valid use. When everything is mandatory, the gain is that the client no longer calls a huge constructor in a fixed order. You can also add a single construct() method (a "director") that collects the fields, fills in the ones the client does not need to know, and calls the big constructor for them.

Key takeaways
  • Builder = many optional fields, chained setters that return self, then build().
  • Simple Factory hides creation but breaks OCP when types are added.
  • Factory Method = one factory per product, so new types do not touch old code.
  • Abstract Factory = one factory per family of products that belong together.

Prototype pattern ▶ 1:34:00 – 2:05:00

Make new objects by cloning a ready-made object instead of building it from scratch: "build + load" becomes "copy + tweak".

The problem

Some objects are very expensive to create. A game "boss enemy" must load a 3D model, animations, an AI engine, sounds, weapons and skills in its constructor. Now the game needs 1,000 zombies, or 100 PUBG players dropping in at the same time. Most of that heavy data is identical for all of them. Running the expensive constructor 1,000 times wastes time and memory.

The teacher's demo: creating 20 game characters the normal way took about 1,100 ms and 181 MB; cloning one base character took about 53 ms and 10 MB.

Real-life picture

Zombies in a game. Every zombie looks the same: same graphics, same rendering, same textures. What differs is small: each one's position, health, kill count, and who damaged it. So you load one zombie fully, then clone it and just move the copy to a new spot.

The idea in one line

Give the object a clone() method so the object itself decides how it is copied. Use it when creation is expensive (I/O, DB, heavy computation) and many objects share most of their data.

Building it step by step

  1. Without the pattern: call Product(id) 10,000 times, so the images, metadata and DB setup are loaded 10,000 times.
  2. Add an interface ProductPrototype with one method, clone(). Every prototype must say how it copies itself.
  3. Add a "copy path" that skips the heavy work. In Java the teacher wrote a private copy constructor Product(Product base) that copies each field from the base. It is private so outsiders must go through clone(), which keeps cloning under the class's control.
  4. Use it: build one base product, then base.clone() and tweak what differs (the id, the position...).
Python
import time
from abc import ABC, abstractmethod


class ProductPrototype(ABC):
    @abstractmethod
    def clone(self) -> "ProductPrototype":
        """Every prototype must say how it copies itself."""


class Product(ProductPrototype):
    def __init__(self, product_id: int):
        # Normal (expensive) path: "build + load"
        self.id = product_id
        self.images = self._load_images()    # heavy
        self.metadata = self._give_metadata() # heavy
        self.db_endpoint = self._db_loading() # heavy

    # --- pretend-expensive helpers (each sleeps 0.3 s) ---
    def _load_images(self) -> list[str]:
        time.sleep(0.3); return ["front.png", "back.png"]

    def _give_metadata(self) -> dict:
        time.sleep(0.3); return {"brand": "Acme", "tags": ["new"]}

    def _db_loading(self) -> str:
        time.sleep(0.3); return "db://products"

    def clone(self) -> "Product":
        # Like Java's private copy constructor: make an object WITHOUT running __init__
        copy_ = Product.__new__(Product)
        copy_.id = self.id
        copy_.images = self.images           # reuse already-computed values
        copy_.metadata = self.metadata
        copy_.db_endpoint = self.db_endpoint
        return copy_                         # "copy + tweak": caller changes what differs

    def __str__(self) -> str:
        return f"Product(id={self.id}, images={self.images})"


# usage
start = time.perf_counter()
base = Product(100)                              # pays the full cost once
print(f"base:  {time.perf_counter() - start:.2f}s")

start = time.perf_counter()
clones = []
for new_id in range(101, 1101):                  # 1000 more products
    p = base.clone()
    p.id = new_id                                # tweak only the unique field
    clones.append(p)
print(f"1000 clones: {time.perf_counter() - start:.3f}s")
print(clones[0], clones[0] is base)
# Output (approx):
# base:  0.90s
# 1000 clones: 0.000s
# Product(id=101, images=['front.png', 'back.png']) False
Java vs Python

Python has no private constructors and no overloading, so you cannot write a second "copy constructor". The trick is Product.__new__(Product), which makes an empty object without running __init__. In everyday Python you simply use the standard copy module: copy.copy(obj) (shallow) or copy.deepcopy(obj) (deep). You can customise them by defining __copy__ / __deepcopy__ on the class. Java has Cloneable + Object.clone(), but most people prefer a copy constructor.

Shallow copy vs deep copy

A shallow copy creates a new outer object, but its fields still point to the same inner objects (lists, dicts, textures). A deep copy also duplicates every inner object, so nothing is shared.

Python
import copy

base = {"name": "zombie", "texture": ["skin.png"], "pos": [0, 0]}

shallow = copy.copy(base)        # new outer dict, SAME inner lists
deep = copy.deepcopy(base)       # new outer dict AND new inner lists

base["texture"].append("blood.png")
print(shallow["texture"])        # ['skin.png', 'blood.png']  -> shared, sees the change
print(deep["texture"])           # ['skin.png']               -> independent

# A clone that shares heavy data but owns its own small state:
z2 = copy.copy(base)
z2["pos"] = [100, 100]           # REPLACE the list (don't mutate the shared one)
print(base["pos"], z2["pos"])    # [0, 0] [100, 100]
classDiagram
  class ProductPrototype {
    +clone() ProductPrototype
  }
  class Product {
    -id
    -images
    -metadata
    -db_endpoint
    +clone() Product
  }
  class Client
  Product ..|> ProductPrototype
  Client --> ProductPrototype : calls clone

Pros, cons, pitfalls

Small correction:

In class it was once said "if cloning an object is expensive, Prototype helps". It is the other way round: Prototype helps when creating an object is expensive and cloning is cheap.

Remember

A copy constructor is one way to implement Prototype. The teacher noted Prototype is rarely asked as a full interview problem, but you may get "what is Prototype?" as a quick check.

Doubt: Isn't a copy constructor the same thing?
→

Yes. A copy constructor is one implementation of Prototype. The pattern just says: expose a clone() so others can copy you cheaply, without knowing your internals.

Doubt: Is the clone a separate object in memory, or does it point to the same object?
→

The clone is a separate object with its own fields (different address). Only the values inside some fields may point to the same shared data, such as a pre-loaded texture. They are not static/shared variables; each object has its own variables A, B, C.

Doubt: If I change the base product's image later, do the clones change too?
→

If the clones share that object by reference (shallow copy) and you mutate it, yes, all of them see the change. If you replace the field with a new object, or deep-copied it, they do not.

Doubt: Why clone at all? Why not just reuse the same object everywhere?
→

Because each copy needs a few of its own values. 1,000 zombies pointing at one object would all stand at (0, 0) with the same health. Prototype is copy plus tweak: share the expensive parts, keep the small per-object state separate.

Doubt: Shouldn't clone() return the interface type (ProductPrototype) instead of Product?
→

Ideally yes, returning the parent type is cleaner abstraction. Returning Product was done in class only to avoid casting and keep the first example simple.

Key takeaways
  • Prototype = clone a pre-built object, then tweak; avoid repeating expensive set-up.
  • The object itself defines clone(), so it controls how it is copied.
  • Shallow copy shares inner objects (fast, but mutations leak); deep copy duplicates them (safe, slower).
  • In Python: copy.copy, copy.deepcopy, __copy__, __deepcopy__.
  • Classic uses: game characters, product templates, documents created from templates.

Decorator pattern ▶ 2:08:00 – 2:48:37

Add new behaviour to an object at runtime by wrapping it in another object that has the same interface, without changing or subclassing the original.

The problem

You have a working execute() function with 100 lines. Now you need 20 more lines to run before it, and on another day a different 30 lines, before or after, in any combination. Editing the tested code each time breaks the Open-Closed Principle and risks breaking what already works.

Real examples from class:

Real-life picture

Pizza. A base pizza costs 200. Add jalapeño topping (+50), then cheese burst (+100). Ask the outer layer "what is your cost?" and it answers "100 + whatever the layer inside me costs", and so on down to the base: 350. After all the toppings it is still a pizza. The teacher's second picture: a computer mouse today has many more features than 20 years ago, but it is still a mouse and you use it the same way.

The idea in one line

A decorator is-a Logger (same interface, so clients don't notice) and has-a Logger (the object it wraps). It does its extra work, then hands the call to the wrapped object. Use it when you need optional features that can be combined in any order at runtime.

Building it step by step

  1. Component interface: Logger with log(message).
  2. Concrete components (the plain pizza bases): ConsoleLogger, FileLogger.
  3. Abstract decorator LoggerDecorator: implements Logger and stores a reference to the logger it wraps. The reference is typed as the interface Logger, not ConsoleLogger, so the same decorator works on any logger. That is the Dependency Inversion Principle: depend on abstractions, not on low-level details. Keeping the reference in the abstract base means each concrete decorator does not repeat it.
  4. Concrete decorators: TimestampLogger adds a timestamp then calls self._logger.log(...); JsonParsingLogger formats as JSON then delegates.
  5. Stack them like toppings: JsonParsingLogger(TimestampLogger(ConsoleLogger())). The outer layer runs first, the base runs last.
Python
import json
import time
from abc import ABC, abstractmethod


# 1. The component interface: everyone "is a" Logger
class Logger(ABC):
    @abstractmethod
    def log(self, message: str) -> None:
        ...


# 2. Concrete components (the "plain pizza bases")
class ConsoleLogger(Logger):
    def log(self, message: str) -> None:
        print(f"[console] {message}")


class FileLogger(Logger):
    def log(self, message: str) -> None:
        print(f"[file] {message}")          # pretend we append to a file


# 3. Abstract decorator: IS-A Logger and HAS-A Logger (the wrapped one)
class LoggerDecorator(Logger, ABC):
    def __init__(self, logger: Logger):
        self._logger = logger                # who am I decorating?


# 4. Concrete decorators: add work, then hand over to the inner logger
class TimestampLogger(LoggerDecorator):
    def log(self, message: str) -> None:
        stamped = f"{int(time.time() * 1000)} {message}"   # extra behaviour
        self._logger.log(stamped)                           # delegate


class JsonParsingLogger(LoggerDecorator):
    def log(self, message: str) -> None:
        print("JSON parsing happening for the message")
        self._logger.log(json.dumps({"msg": message}))


# usage
ConsoleLogger().log("Pradeep, without any decorator")
TimestampLogger(FileLogger()).log("message to the file")

# Stack layers like pizza toppings: JSON -> Timestamp -> Console
logger = JsonParsingLogger(TimestampLogger(ConsoleLogger()))
logger.log("Pradeep sending message")
# Output:
# [console] Pradeep, without any decorator
# [file] 1791403953481 message to the file
# JSON parsing happening for the message
# [console] 1791403953481 {"msg": "Pradeep sending message"}

Notice: ConsoleLogger and FileLogger were never touched. They don't even know they are being wrapped. Switching the base from console to file is a one-word change at the call site.

classDiagram
  class Logger {
    +log(message)
  }
  class ConsoleLogger {
    +log(message)
  }
  class FileLogger {
    +log(message)
  }
  class LoggerDecorator {
    #logger Logger
    +log(message)
  }
  class TimestampLogger {
    +log(message)
  }
  class JsonParsingLogger {
    +log(message)
  }
  ConsoleLogger ..|> Logger
  FileLogger ..|> Logger
  LoggerDecorator ..|> Logger
  LoggerDecorator o-- Logger : wraps
  TimestampLogger --|> LoggerDecorator
  JsonParsingLogger --|> LoggerDecorator
Remember

Decorator is the pattern with both an is-a and a has-a link to the same type: LoggerDecorator is a Logger and has a Logger. Draw this in an interview and the pattern is instantly recognisable.

Small correction:

It was said that if someone asks "which pattern has both is-a and has-a", the answer is Decorator. Decorator is the classic answer, but Composite (and often Proxy) has the same shape too. The difference is intent: Decorator adds behaviour to one wrapped object, Composite treats a tree of children like a single object.

Python's @decorator is related, but not the same

Python's @something syntax wraps a function (or class) with another function when it is defined. It shares the idea of "wrap and add behaviour", and is great for retry, logging and timing. But the GoF Decorator pattern wraps objects at runtime, behind a shared interface, and lets you choose and stack layers per object while the program runs. @ is applied once, at definition time, to every call.

Python
import time

def with_timestamp(func):                 # a Python function decorator
    def wrapper(message: str) -> None:
        func(f"{int(time.time())} {message}")   # extra work, then call the original
    return wrapper

@with_timestamp                           # same as: log = with_timestamp(log)
def log(message: str) -> None:
    print(message)

log("hello")   # 1774771200 hello  -> every call is wrapped, always

Pros, cons, pitfalls

Doubt: Why does LoggerDecorator implement Logger if it already holds a Logger inside?
→

To keep parity for the client. A decorated logger must still be usable as a logger, with the same log() method. Otherwise the client would face a new method name and wonder which one to call. Implementing Logger is also what lets you wrap a decorator inside another decorator (stacking).

Doubt: Why keep the wrapped logger in the abstract decorator and not in each concrete one?
→

Every decorator needs to know "who am I decorating". Putting that field and constructor in the abstract base means each new decorator just calls super().__init__(logger) and focuses on its own extra work.

Doubt: Is retry a decorator use case?
→

Yes. Retry, logging, JSON conversion, caching, auth checks: any add-on you want around existing behaviour, without touching that behaviour.

Doubt: (Testing background) I have one report and want TestNG, JUnit, NUnit and custom report types. Is that Decorator?
→

Only if each one wraps a base report and adds something on top (final report = base report + add-on). Decorator is always a wrapper around an existing object. If they are just different kinds of report, that is plain polymorphism (or a Factory to create them).

Key takeaways
  • Decorator adds behaviour dynamically by wrapping, not by subclassing or editing.
  • Shape: decorator is-a Component and has-a Component; calls go outer layer to inner layer, down to the base.
  • Solves class explosion when features combine in any order.
  • Type the wrapped field as the interface, so a decorator works on any base (Dependency Inversion).
  • Python's @decorator is a cousin: function wrapping at definition time, not runtime object wrapping.

LLD problem: Snake and Ladder ▶ 2:49:30 – 3:12:50

An easy first LLD problem: practise the standard framework of requirements, entities, class design, code and edge cases.

The framework for any LLD problem

  1. Requirements: functional (what it must do) and non-functional (qualities such as clean code or thread safety).
  2. Core entities: the classes and interfaces. Tip: pick out the nouns in the requirements.
  3. Class diagram / class design.
  4. Working code.
  5. Edge cases.

Practise 10 to 20 problems this way and the framework becomes automatic. Always write at least 4 or 5 requirement points in an interview. It gives you time to think, and it shows you did not just memorise a solution.

1. Requirements

FunctionalNon-functional
  • Support multiple players.
  • There is a board with numbered cells (1 to 100).
  • A dice that players roll (1 to 6).
  • Snakes take you down, ladders take you up.
  • Players take turns, one after another.
  • The game ends when a player reaches 100 (or more).
  • Clean code following SOLID.
  • Easy to extend (more players, bigger board, other dice).
  • Thread safety: not really needed, because turns are strictly one after another; mention it only for extensibility.

2. Core entities

Design decisions made in class

3. Class diagram

classDiagram
  class GameDriver {
    +run(names)
  }
  class Game {
    -board Board
    -dice Dice
    -turns Queue
    +play() Player
  }
  class Board {
    -start int
    -end int
    -jumps Dict
    +next_position(pos) int
  }
  class Dice {
    -faces int
    +roll() int
  }
  class Player {
    -player_id int
    -name str
    -position int
  }
  GameDriver --> Game : creates
  Game *-- Board
  Game *-- Dice
  Game o-- Player

4. Code (small, playable version)

A 30-cell board with 2 snakes and 2 ladders so a game ends quickly. Pass a seed to get the same game every time. Change size=100 for the real board.

Python
import random
from collections import deque
from dataclasses import dataclass


@dataclass
class Player:
    player_id: int
    name: str
    position: int = 1                    # everyone starts on cell 1


class Dice:
    def __init__(self, faces: int = 6):
        self.faces = faces

    def roll(self) -> int:
        return random.randint(1, self.faces)   # 1..6, both ends included


class Board:
    def __init__(self, size: int = 100, snakes: int = 2, ladders: int = 2):
        self.start = 1
        self.end = size
        # start cell -> end cell. Snake if start > end, ladder if start < end.
        self.jumps: dict[int, int] = {}
        self._place(snakes, is_snake=True)
        self._place(ladders, is_snake=False)

    def _place(self, count: int, is_snake: bool) -> None:
        placed = 0
        while placed < count:
            a, b = random.sample(range(2, self.end), 2)   # never on 1 or 100
            head, tail = (max(a, b), min(a, b)) if is_snake else (min(a, b), max(a, b))
            # skip cells already used as a start or end (no chains, no clashes)
            used = set(self.jumps) | set(self.jumps.values())
            if head in used or tail in used:
                continue
            self.jumps[head] = tail
            placed += 1

    def next_position(self, position: int) -> int:
        # Board knows the snakes and ladders, so Board answers "where do I land?"
        return self.jumps.get(position, position)


class Game:
    def __init__(self, board: Board, dice: Dice, players: list[Player]):
        self.board = board
        self.dice = dice
        self.turns = deque(players)      # works for 2 or N players

    def play(self) -> Player:
        while True:                      # the game loop
            player = self.turns.popleft()
            roll = self.dice.roll()
            moved = player.position + roll
            if moved >= self.board.end:      # teacher's rule: reach or cross the end -> win
                print(f"{player.name} rolled {roll}: {player.position} -> {self.board.end}")
                return player                # exit the game loop: we have a winner
            final = self.board.next_position(moved)
            note = ""
            if final < moved:
                note = f" (snake at {moved})"
            elif final > moved:
                note = f" (ladder at {moved})"
            print(f"{player.name} rolled {roll}: {player.position} -> {final}{note}")
            player.position = final
            self.turns.append(player)    # back of the queue: next player's turn


class GameDriver:
    """Orchestrator: builds the objects and starts the game for the client."""
    @staticmethod
    def run(names: list[str], seed: int | None = None) -> None:
        random.seed(seed)
        board = Board(size=30, snakes=2, ladders=2)   # small board for a quick game
        print("snakes/ladders:", board.jumps)
        players = [Player(i, n) for i, n in enumerate(names, start=1)]
        winner = Game(board, Dice(), players).play()
        print(f"Winner: {winner.name}")


# usage
if __name__ == "__main__":
    GameDriver.run(["Alice", "Bob"], seed=7)

Sample run (seed 7, trimmed):

Python
snakes/ladders: {12: 6, 22: 14, 3: 4, 19: 28}
Alice rolled 1: 1 -> 2
Bob rolled 3: 1 -> 4
Alice rolled 5: 2 -> 7
...
Alice rolled 5: 7 -> 6 (snake at 12)
...
Bob rolled 6: 15 -> 21
Alice rolled 6: 18 -> 24
Bob rolled 5: 21 -> 26
Alice rolled 1: 24 -> 25
Bob rolled 5: 26 -> 30
Winner: Bob
Java vs Python

In Java, random.nextInt(6) gives 0 to 5, so the teacher wrote nextInt(6) + 1 to get 1 to 6. Python's random.randint(1, 6) includes both ends, so no + 1 is needed. Java's ArrayDeque maps to Python's collections.deque.

How the game loop works

Every game has a game loop: a while loop that keeps playing turns until the game is finished. In each turn: take the current player, roll the dice, compute position + roll, check whether you won, ask the Board for the final cell (after any snake or ladder), update the player, and pass the turn on.

5. Edge cases

Which patterns and principles are used?

Doubt: Where are the snake and ladder positions stored?
→

In the Board's dictionary (Java HashMap) jumps, as start -> end, filled when the board is created.

Doubt: next_position tells us if we landed on a snake or ladder. Do we update the player's position for a snake too?
→

Yes. Whatever the final cell is after the snake or ladder, that becomes the player's new position. Since there are no chained jumps, one lookup is enough.

Doubt: Why nextInt(6) + 1 for the dice?
→

Java's nextInt(6) returns 0 to 5 (the upper bound is excluded). Adding 1 shifts it to 1 to 6.

Homework: write your own Snake and Ladder from scratch without looking at the solution code, using a queue for N players. Also read about LRU Cache before the next class.

Key takeaways
  • Follow the framework: requirements, entities (nouns), class diagram, code, edge cases.
  • Don't model what has no state: positions are plain integers, no Cell grid needed.
  • Snakes and ladders are both jumps: one start -> end dict handles both.
  • Put logic where the data lives: Board gives the next position.
  • Game loop + queue of players handles any number of players.