Jackpot Machine LLD: Reels, a Paytable, and Money That Never Lies
A low-level design walkthrough of a jackpot slot machine: independent reels with injected randomness, the paytable as data instead of if-chains, and a credits ledger that always balances.
"Design a jackpot machine." It's the shortest game in the LLD queue — pull a lever, three symbols, win or don't — which is exactly why it's a favourite screener: with no algorithmic place to hide, the interviewer gets a clean look at your structure. Do payouts live in an if-chain or in data? Is the randomness testable? Can the credits ledger ever, under any interleaving of events, lose a rupee?
Short game, sharp questions. Let's build it so all three answers are the good ones.
Queue stop #6. The randomness-through-the-constructor habit from Snake & Ladder and Minesweeper does its heaviest lifting yet — this time the untestable thing is people's money.
Let's start nowhere near a computer
A village fair raffle: three drums, each stuffed with tokens — some cherries, some bells, one or two sevens. The operator pulls one token from each drum and lays the three on the counter. Then — and this is the part to watch — she doesn't decide anything. She looks the combination up on a printed card nailed to the stall: three sevens, fifty times your stake; any pair, double; otherwise, thanks for playing.
Three independent drums. One printed card. A cash box that takes the stake before the draw and pays after the lookup, in that order, every time. The fair has been running this exact architecture for a century — our job is just to type it.
Where the printed card keeps appearing
- Pricing engines — shipping rates, tax brackets, discount tiers: every "lookup card" that product will tweak weekly must be data, or every tweak is a deploy.
- Game loot tables — drop rates in every RPG are this machine wearing armor.
- The vending machine's cousin — both are money-handling state boxes; this one adds randomness to the till.
Step 1 — Functional requirements (sentences first)
What the machine must do, as plain sentences — the functional requirements.
- A player can load credits into the machine.
- A spin costs a bet, draws one symbol from each of three reels, and pays out per the paytable.
- Three matching symbols pay that symbol's multiplier; any pair pays double; anything else pays nothing.
- A player can't bet more credits than they have, and a bet must be positive.
- The operator can configure the reels and the paytable — without touching the machine's code.
The last sentence is the design brief in disguise: reels and payouts are configuration. And the fourth is a stance, not a footnote — the machine refuses an impossible bet before any randomness runs, because a payout you can't fund is worse than a spin that never happened. The order of operations is a feature.
Step 2 — Non-functional requirements
At class level the non-functional requirements are different words for the same idea — how well, not just what — and for a money-handling machine they are the design:
- Fairness & correctness. Every payout matches the published paytable exactly, and the draw is uniform over the strip — no hidden bias, no
Math.randomyou can't reproduce. This is the whole reason the thing is licensed. - Testability. A seeded generator makes any spin reproducible, so payout logic is checkable without waiting on luck — and a regulator can replay a session draw-for-draw.
- Safety & atomicity. The bet is debited and the win credited in one coherent order; the ledger reconciles to "credits = loaded − bets + payouts" after every spin, under any interleaving.
- Extensibility. New symbols, extra paylines, and new payout rules plug in as data — never as another branch in an if-chain.
Listing them is the easy half; the design only earns them if it fulfills them. Here's the contract — each requirement and the mechanism that keeps it:
| Requirement | How this design fulfills it |
|---|---|
| Fairness / correctness | the paytable is a Map looked up directly; the draw is one uniform nextInt over the strip — Steps 4, 5 |
| Testability | the generator is injected, so a seed scripts every draw and replays every payout — Step 5 |
| Safety / atomicity | debit → draw → look up → credit, in one synchronized method; the ledger always balances — Step 6 |
| Extensibility | symbols, odds, and payouts are data — a new entry, not a new branch — Steps 4, 5 |
Every trade-off below is chosen to keep one of these.
Step 3 — Nouns → classes
- Symbol — an enum:
SEVEN, BELL, CHERRY, LEMON. - Reel — a strip of symbols plus an injected
Random; one skill:spin()returns a symbol. Three independent reels — real machines weight each reel differently, and our model gets that for free because each reel owns its own strip. - PayTable — the printed card: data in, multiplier out.
- SlotMachine — the conductor: the credits ledger and the spin workflow.
Which structure — and why. The paytable is a Map<Symbol, Integer>, not a chain of ifs — and that's the load-bearing choice, not a default. A map is the extensibility NFR: a new symbol is a new key, a payout tweak is a new value, and neither touches the lookup code. The reel's odds are a List<Symbol> strip rather than a weight formula, because a list keeps the draw a single uniform nextInt — the fairness NFR you can read at a glance and a regulator can audit. And money is a long of minor units (credits, not rupees-and-paise floats), so the safety NFR never loses a fraction to rounding — integer arithmetic is exact, and the ledger reconciles to the cent.
Step 4 — The paytable is data, not code
Here's the question this whole game exists to ask. The rookie writes:
if (a == SEVEN && b == SEVEN && c == SEVEN) return 50;
else if (a == BELL && b == BELL && c == BELL) return 15;
else if (a == CHERRY && /* …forever… */ )Every new symbol grows the chain; every payout tweak is a code change, a review, a deploy. Now the printed card instead:
public final class PayTable {
private final Map<Symbol, Integer> tripleMultiplier; // the printed card
private final int pairMultiplier;
public PayTable(Map<Symbol, Integer> tripleMultiplier, int pairMultiplier) {
this.tripleMultiplier = Map.copyOf(tripleMultiplier);
this.pairMultiplier = pairMultiplier;
}
public int multiplierFor(List<Symbol> line) {
if (line.get(0) == line.get(1) && line.get(1) == line.get(2)) {
return tripleMultiplier.getOrDefault(line.get(0), 0);
}
if (line.get(0) == line.get(1) || line.get(1) == line.get(2)
|| line.get(0) == line.get(2)) {
return pairMultiplier;
}
return 0;
}
}New symbol? New map entry. Seasonal double-payout week? A different PayTable instance, injected. The rules change without the machine changing — that's the entire grade on this question.
Step 5 — Reels: randomness someone can audit
public final class Reel {
private final List<Symbol> strip;
private final Random random;
public Reel(List<Symbol> strip, Random random) {
if (strip.isEmpty()) {
throw new IllegalArgumentException("a reel needs symbols");
}
this.strip = List.copyOf(strip);
this.random = random;
}
public Symbol spin() {
return strip.get(random.nextInt(strip.size()));
}
}Two quiet design points. The odds live in the strip: want sevens rare? Put one SEVEN among twenty entries — probability as data again, sitting right next to the paytable it must balance against. And the generator is injected, not a static call — a seam in the constructor, never a buried Math.random. Seed it and every draw is reproducible, so a test (or a gaming regulator — this is a licensed device in real life) can script the whole session and audit every payout claim. That's the testability NFR bought with one constructor parameter; the fairness NFR rides along, because a uniform nextInt over the strip is the only draw there is.
Step 6 — The machine: a ledger with one rule
public synchronized SpinResult spin(long bet) {
if (bet <= 0) {
throw new IllegalArgumentException("bet must be positive");
}
if (bet > credits) {
throw new IllegalStateException("not enough credits: have " + credits + ", bet " + bet);
}
credits -= bet; // debit FIRST…
List<Symbol> line = List.of(reels.get(0).spin(), reels.get(1).spin(), reels.get(2).spin());
long payout = (long) payTable.multiplierFor(line) * bet; // …look up…
credits += payout; // …credit LAST
return new SpinResult(line, payout);
}Debit, draw, look up, credit — always in that order, inside one synchronized method. If anything throws mid-way (it can't here, but the discipline is the point), the money trail is still coherent: the bet was taken before any randomness ran. It's the same every-refusal-before-anything-irreversible rule the vending machine lived by, applied to a ledger.
The two guard clauses at the top are the edges the interviewer is really probing. A zero-or-negative bet is rejected before anything moves — you can't refund a spin that was never paid for. An insufficient balance (bet > credits) is refused at the door, so the ledger never goes negative. The one edge this single-machine model names but doesn't yet hold is a win that exceeds the house pool — a progressive jackpot bigger than the cash box — which is where a separate pool account and a "credit only what the pool can fund" check enter (the follow-up ladder picks this up). Each guard is the safety NFR refusing to let the ledger tell a lie.
The honest aside interviewers enjoy: real slot machines don't pick uniformly — regulated "weighted reels" make jackpots rarer than the symbols suggest, and the law requires the published return-to-player to match the math. Which is one more argument for odds-as-data: you can't audit an if-chain.
Step 7 — Trade-offs (each one keeping an NFR)
The last column is the discipline: every choice keeps one of the promises from Step 2 — that's what designing to the non-functional requirements looks like.
| Decision | The tempting alternative | Why ours wins | Keeps |
|---|---|---|---|
paytable as a Map | an if/else payout chain | a new symbol or payout is data, not a code change, review, and deploy | extensibility |
| injected, seedable generator | a static Math.random call | every spin replays from a seed — testable, and auditable by a regulator | testability |
odds as a List<Symbol> strip | a weight formula in the spin code | the draw stays one uniform nextInt anyone can read and verify | fairness |
long minor units for money | double rupees | integer arithmetic is exact; the ledger reconciles with no rounding drift | safety |
| debit → draw → credit, one lock | credit the win, then take the bet | the ledger is coherent at every instant; a mid-spin throw can't leak money | atomicity |
Growth — when one machine isn't enough. The first knobs are the strips (odds) and the paytable (payouts), both data. Beyond that you don't make one machine cleverer — you compose: a progressive jackpot is a separate pool account fed by a slice of each bet and a "fund-the-win" guard before crediting; a wall of machines is a ledger and lock per machine, the debit-before-draw order unchanged on each. Same instinct as the thread pool's bulkhead — keep each money box small and independent, not one box trying to be smart.
The complete implementation
package dev.fiveyear.slots;
public enum Symbol { SEVEN, BELL, CHERRY, LEMON }package dev.fiveyear.slots;
import java.util.List;
import java.util.Map;
import java.util.Random;
public final class Reel {
private final List<Symbol> strip;
private final Random random;
public Reel(List<Symbol> strip, Random random) {
if (strip.isEmpty()) {
throw new IllegalArgumentException("a reel needs symbols");
}
this.strip = List.copyOf(strip);
this.random = random;
}
public Symbol spin() {
return strip.get(random.nextInt(strip.size()));
}
}
public final class PayTable {
private final Map<Symbol, Integer> tripleMultiplier;
private final int pairMultiplier;
public PayTable(Map<Symbol, Integer> tripleMultiplier, int pairMultiplier) {
this.tripleMultiplier = Map.copyOf(tripleMultiplier);
this.pairMultiplier = pairMultiplier;
}
public int multiplierFor(List<Symbol> line) {
if (line.get(0) == line.get(1) && line.get(1) == line.get(2)) {
return tripleMultiplier.getOrDefault(line.get(0), 0);
}
if (line.get(0) == line.get(1) || line.get(1) == line.get(2)
|| line.get(0) == line.get(2)) {
return pairMultiplier;
}
return 0;
}
}package dev.fiveyear.slots;
import java.util.List;
public final class SlotMachine {
public record SpinResult(List<Symbol> line, long payout) {}
private final List<Reel> reels;
private final PayTable payTable;
private long credits;
public SlotMachine(List<Reel> reels, PayTable payTable) {
if (reels.size() != 3) {
throw new IllegalArgumentException("this machine takes exactly 3 reels");
}
this.reels = List.copyOf(reels);
this.payTable = payTable;
}
public synchronized void insertCredits(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
credits += amount;
}
public synchronized SpinResult spin(long bet) {
if (bet <= 0) {
throw new IllegalArgumentException("bet must be positive");
}
if (bet > credits) {
throw new IllegalStateException("not enough credits: have " + credits + ", bet " + bet);
}
credits -= bet;
List<Symbol> line = List.of(reels.get(0).spin(), reels.get(1).spin(), reels.get(2).spin());
long payout = (long) payTable.multiplierFor(line) * bet;
credits += payout;
return new SpinResult(line, payout);
}
public synchronized long credits() {
return credits;
}
}A night at the fair:
PayTable card = new PayTable(
Map.of(Symbol.SEVEN, 50, Symbol.BELL, 15, Symbol.CHERRY, 8, Symbol.LEMON, 4), 2);
List<Symbol> strip = List.of(Symbol.SEVEN, Symbol.BELL, Symbol.CHERRY, Symbol.LEMON);
Random rng = new Random();
SlotMachine machine = new SlotMachine(
List.of(new Reel(strip, rng), new Reel(strip, rng), new Reel(strip, rng)), card);
machine.insertCredits(100);
SpinResult r = machine.spin(5); // credits: 95 + payout
// triple SEVEN → payout 250 · any pair → 10 · nothing → 0
// and credits() always equals: 100 − bets + payouts. always.The interview corner
Clarify before you code: Exactly three reels? One payline or several? Is there a regulatory return-to-player target the math must hit?
The follow-up ladder:
- "Make sevens rarer." Weights live in the strips — data — and RTP is recomputed from strips × paytable. No machine code changes.
- "Add a progressive jackpot." A pool account fed by a slice of every bet, paid on the rare combo: one more row of data, two more ledger lines.
- "Five reels, twenty paylines." The PayTable evaluates line patterns instead of one triple; the spin pipeline is untouched — that's what the separation bought.
- "Prove fairness to a regulator." Injected RNG means any session replays from a seed; an append-only spin log makes every payout auditable. You can't audit an if-chain.
- "Concurrent players on a wall of machines." A ledger per machine, one lock each; the debit → draw → look up → credit order survives unchanged.
Mistakes that fail the round: payout if-chains; randomness that can't be seeded or scripted; crediting winnings before the bet was debited.
Where to go from here
Pocket version: odds live in the strips, payouts live in the card, the ledger debits before it draws — and all three are data someone can audit.
- Add a progressive jackpot — a counter fed by a slice of every bet, paid on triple sevens. Notice it's a fourth row of data plus two ledger lines.
- Weighted reels — replace the uniform pick with a weight map per reel and recompute the return-to-player; the machine doesn't change, the card does.
- Next in the queue: Tetris — back to deep game mechanics, where one collision function runs the whole show.
Next time you see a slot machine blinking in an airport, you'll know the whole box: three lists, one map, and a ledger that's never once been creative.