Tetris LLD: One Collision Check Runs the Whole Game
A low-level design walkthrough of Tetris: why the falling piece never lives on the board, rotation as pure arithmetic, line clears as a filter, and the complete implementation of the classic.
"Design Tetris." Everyone alive has played it; almost nobody can structure it on a whiteboard. Candidates drown in the same three places, every time: they hardcode twenty-eight piece orientations, they "move" pieces by erasing and redrawing cells on the board, and their left-arrow code, right-arrow code, gravity code, and rotation code are four separately-buggy copies of the same idea.
The escape from all three is a single sentence: the falling piece is not on the board, and every action in the game is the same question — "would the piece fit there?" Build that one question well and Tetris stops being twenty special cases and becomes a loop, a filter, and one line of rotation arithmetic. Let's build it.
Let's start nowhere near a computer
Picture a printed photo of a half-packed moving van — boxes settled on the floor, gaps everywhere. Now hold a cardboard cutout of an L-shaped box an inch in front of the photo. Slide the cutout left, right, down; spin it. The photo never changes. To ask "can the box go there?", you just check whether the cutout overlaps any printed box or pokes outside the van.
Only when you're happy does the box become real: you glue the cutout onto the photo, and now it's cargo. That's Tetris. The photo is the board — settled blocks only. The cutout is the falling piece — a shape and a position, hovering. Moving the piece is moving a cutout, not repainting a photo.
The rookie design merges them — piece cells written into the board grid, erased and rewritten on every twitch. Every bug in amateur Tetris clones (pieces leaving smears, collisions with yourself, haunted rotations) is that one decision.
Where the cutout trick shows up beyond games
- Text editors — your cursor and selection float over the document; they're not in the buffer.
- Drag-and-drop UIs — the ghost you drag is a cutout; the drop is the glue moment.
- Database transactions — uncommitted changes hover over the real data until commit. "Candidate state, validated, then merged" is half of software.
Step 1 — Functional requirements (sentences first)
What the game must do, as plain sentences — the functional requirements.
- Pieces (the seven tetrominoes) fall one row per tick into a 10×20 well.
- The player can shift a piece left/right, rotate it, or drop it.
- A piece settles ("locks") when it can't fall further; full rows then vanish and everything above falls.
- A new piece spawns at the top; if it doesn't fit, the game is over.
- Any action that would overlap settled blocks or the walls simply doesn't happen.
That last sentence is the design: illegal actions are non-events, not errors. Mashing left against the wall isn't an exception — it's a no.
Step 2 — Non-functional requirements
At class level the non-functional requirements aren't about scale — there are no servers here — they're about how correctly and cheaply each move resolves. For a pure-logic game like Tetris they are the design:
- Correctness. A piece moves or rotates only into empty, in-bounds cells — the collision test is exact, never approximate. A piece locks the instant it can't fall, full rows clear and everything above shifts down, and the game ends precisely when a freshly spawned piece doesn't fit.
- Performance. Checking a move is O(cells-in-piece) — four — not O(board); a line-clear touches the board once, with no full rescan on every tick.
- Extensibility. The piece set, the rotation rule, the board size, and scoring are all data or seams. Adding a piece is a new bitmap, not a new branch in the collision code.
- Testability. A seeded or scripted piece supply makes a game reproducible, and every operation — move, rotate, lock, clear — is a pure board transform you can assert on in isolation.
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 |
|---|---|
| Correctness | one exact fits test gates every move; lock, clear, and spawn-fails-game-over all route through it — Steps 4, 6 |
| Performance | fits scans only the piece's filled cells (≤ 4); the line-clear is one filter pass over the board — Steps 4, 6 |
| Extensibility | shapes are an enum of bitmaps, rotation is a formula, board size is a constructor arg — Steps 3, 5 |
| Testability | the piece supply is injected; every action is a pure propose-check-commit transform — Steps 3, 4 |
Every trade-off below is chosen to keep one of these.
Step 3 — Nouns → classes
- Tetromino — an enum of seven shapes, each a tiny
int[][]bitmap in its bounding box. Data, not classes. - Board —
int[][], settled cells only. The photo. - The piece — a shape, a row, a column. Three fields. The cutout.
- Game — the conductor: tick, shift, rotate, lock, clear.
- The piece supply — injected, because it's randomness: tests script the exact sequence of pieces (the Snake & Ladder die, fourth appearance).
public enum Tetromino {
I(new int[][] {{1, 1, 1, 1}}),
O(new int[][] {{1, 1}, {1, 1}}),
T(new int[][] {{1, 1, 1}, {0, 1, 0}}),
S(new int[][] {{0, 1, 1}, {1, 1, 0}}),
Z(new int[][] {{1, 1, 0}, {0, 1, 1}}),
J(new int[][] {{1, 0, 0}, {1, 1, 1}}),
L(new int[][] {{0, 0, 1}, {1, 1, 1}});
// …seven bitmaps. rotation is NOT stored — it's computed.
}Which structure — and why. The board is a plain grid of filled/empty cells (int[][], 0 or a colour) — settled blocks only — because that's exactly what the collision test needs to read, and a flat array makes "is (r, c) occupied?" an O(1) lookup that keeps performance honest. The piece is not stored on that grid; it's a tiny bitmap plus a position, the smallest thing that answers "which four cells would this occupy?" — which is why a move costs four checks, not a board scan. Keeping the shapes as an enum of bitmaps rather than hand-drawn orientations is the extensibility seam (a new piece is one row), and routing randomness through an injected piece supply is the testability seam (a scripted sequence makes any game replayable). Each structure is picked to keep a named requirement, not for convenience.
Step 4 — The one question: does it fit?
Here's the function the entire game routes through. Given a candidate shape at a candidate position, does every filled cell land in bounds and on empty board?
private boolean fits(int[][] shape, int atRow, int atCol) {
for (int r = 0; r < shape.length; r++)
for (int c = 0; c < shape[r].length; c++) {
if (shape[r][c] == 0) continue;
int br = atRow + r, bc = atCol + c;
if (br < 0 || br >= rows || bc < 0 || bc >= cols) return false; // wall/floor
if (board[br][bc] != 0) return false; // settled block
}
return true;
}Now watch every player action become the same three lines — propose, check, commit:
public boolean moveLeft() { return tryMove(shape, pieceRow, pieceCol - 1); }
public boolean moveRight() { return tryMove(shape, pieceRow, pieceCol + 1); }
public boolean rotate() { return tryMove(rotated(shape), pieceRow, pieceCol); }
private boolean tryMove(int[][] newShape, int newRow, int newCol) {
if (!fits(newShape, newRow, newCol)) {
return false; // illegal actions are non-events
}
shape = newShape;
pieceRow = newRow;
pieceCol = newCol;
return true;
}Left, right, rotation, gravity — one validator. When the interviewer asks "how do you stop rotation through a wall?", the answer is "the same way I stop everything: it's a candidate that fails fits." That sentence is the design grade.
Step 5 — Rotation is arithmetic, not artwork
The twenty-eight-orientations trap: storing four hand-drawn bitmaps per piece. But rotating a grid 90° clockwise is one formula — the cell at (r, c) moves to (c, R−1−r):
static int[][] rotated(int[][] shape) {
int rows = shape.length, cols = shape[0].length;
int[][] out = new int[cols][rows];
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++) {
out[c][rows - 1 - r] = shape[r][c];
}
return out;
}Pure function, trivially testable, works for shapes of any size — and because rotation produces just another candidate shape, the wall problem was already solved one section ago.
Honesty beat: real Tetris adds wall kicks — a rejected rotation retries
shifted one cell left/right before giving up. In our design that's two more
tryMove calls in rotate(). Name it, offer it, don't build it unasked.
Step 6 — Gravity, locking, and the line clear
The tick is gravity wearing the same three lines: try to move down; if you can't, the cutout gets glued:
public void tick() {
if (status != Status.PLAYING) throw new IllegalStateException("game over");
if (!tryMove(shape, pieceRow + 1, pieceCol)) {
lockPiece(); // glue the cutout onto the photo
clearFullRows();
spawn(); // game over lives here: the new piece must fit
}
}And the moment everyone remembers — the line clear — is not cell-shuffling. It's a filter:
private void clearFullRows() {
List<int[]> kept = new ArrayList<>();
for (int[] row : board) {
if (IntStream.of(row).anyMatch(cell -> cell == 0)) {
kept.add(row); // keep the unfull rows, in order
}
}
int cleared = rows - kept.size();
linesCleared += cleared;
for (int i = 0; i < cleared; i++) {
kept.add(0, new int[cols]); // fresh empty rows drop in on top
}
board = kept.toArray(new int[0][]);
}Keep what isn't full, top up with empties — gravity for the stack happens by list position, no per-cell copying. (And yes, that's a stream doing the row check — a filter problem met a filter tool.)
Step 7 — Patterns?
The question: what varies? The piece supply does — random for play, scripted for tests, and real Tetris uses a "7-bag" shuffle so droughts can't happen. That's one injected interface. Everything else — shapes, rotation, collision — is fixed rules: plain code, as usual.
Step 8 — 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 |
|---|---|---|---|
| piece floats; board holds settled only | write the piece into the grid | no erase-and-redraw smears; the move is a check, not a repaint | correctness |
fits scans the piece's cells (≤ 4) | rescan the whole board each move | a move is O(4), not O(rows × cols), every tick | performance |
| rotation by formula | four hand-drawn bitmaps per piece | one O(cells) transform, no 28 orientations to keep in sync | extensibility |
| line-clear as a filter + top-up | shift every cell down per cleared row | one pass keeps the unfull rows in order; multi-row clears fall out free | performance |
| reject illegal moves as non-events | throw on a wall/overlap | mashing into a wall is a quiet false; rotate-against-wall is the same no | correctness |
| injected piece supply | new Random() inside the game | a scripted sequence makes any game replayable in a test | testability |
Growth — when the well gets bigger. The board size is already a constructor argument, so a wider or taller well costs nothing; fits and the clear-filter are written against rows/cols, not the literal 10×20. The edges to name before they bite: a rotation jammed against a wall (our answer is reject, with wall-kicks as the named upgrade in Step 5's callout), a spawn that doesn't fit (that is game-over, handled in spawn), a simultaneous multi-row clear (the filter handles it without special-casing), and a shift into an occupied cell (a non-event, like every other illegal move). None of these grow the code — they were already the one question, asked again.
The complete implementation
package dev.fiveyear.tetris;
public enum Tetromino {
I(new int[][] {{1, 1, 1, 1}}),
O(new int[][] {{1, 1}, {1, 1}}),
T(new int[][] {{1, 1, 1}, {0, 1, 0}}),
S(new int[][] {{0, 1, 1}, {1, 1, 0}}),
Z(new int[][] {{1, 1, 0}, {0, 1, 1}}),
J(new int[][] {{1, 0, 0}, {1, 1, 1}}),
L(new int[][] {{0, 0, 1}, {1, 1, 1}});
private final int[][] shape;
Tetromino(int[][] shape) {
this.shape = shape;
}
int[][] shape() {
int[][] copy = new int[shape.length][];
for (int r = 0; r < shape.length; r++) {
copy[r] = shape[r].clone();
}
return copy;
}
}package dev.fiveyear.tetris;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;
import java.util.stream.IntStream;
public final class Tetris {
public enum Status { PLAYING, GAME_OVER }
private final int rows;
private final int cols;
private final Supplier<Tetromino> pieceSupply; // injected: random in play, scripted in tests
private int[][] board; // settled cells ONLY
private int[][] shape; // the floating cutout…
private int pieceRow;
private int pieceCol; // …and where it hovers
private int linesCleared;
private Status status = Status.PLAYING;
public Tetris(int rows, int cols, Supplier<Tetromino> pieceSupply) {
if (rows < 4 || cols < 4) {
throw new IllegalArgumentException("well too small");
}
this.rows = rows;
this.cols = cols;
this.pieceSupply = pieceSupply;
this.board = new int[rows][cols];
spawn();
}
public boolean moveLeft() {
requirePlaying();
return tryMove(shape, pieceRow, pieceCol - 1);
}
public boolean moveRight() {
requirePlaying();
return tryMove(shape, pieceRow, pieceCol + 1);
}
public boolean rotate() {
requirePlaying();
return tryMove(rotated(shape), pieceRow, pieceCol);
}
/** Gravity: one row down, or lock + clear + spawn. */
public void tick() {
requirePlaying();
if (!tryMove(shape, pieceRow + 1, pieceCol)) {
lockPiece();
clearFullRows();
spawn();
}
}
/** Slam to the floor and settle immediately. */
public void hardDrop() {
requirePlaying();
while (tryMove(shape, pieceRow + 1, pieceCol)) {
// keep falling
}
lockPiece();
clearFullRows();
spawn();
}
static int[][] rotated(int[][] shape) {
int r0 = shape.length, c0 = shape[0].length;
int[][] out = new int[c0][r0];
for (int r = 0; r < r0; r++)
for (int c = 0; c < c0; c++) {
out[c][r0 - 1 - r] = shape[r][c];
}
return out;
}
private boolean tryMove(int[][] newShape, int newRow, int newCol) {
if (!fits(newShape, newRow, newCol)) {
return false;
}
shape = newShape;
pieceRow = newRow;
pieceCol = newCol;
return true;
}
private boolean fits(int[][] s, int atRow, int atCol) {
for (int r = 0; r < s.length; r++)
for (int c = 0; c < s[r].length; c++) {
if (s[r][c] == 0) continue;
int br = atRow + r, bc = atCol + c;
if (br < 0 || br >= rows || bc < 0 || bc >= cols) return false;
if (board[br][bc] != 0) return false;
}
return true;
}
private void lockPiece() {
for (int r = 0; r < shape.length; r++)
for (int c = 0; c < shape[r].length; c++) {
if (shape[r][c] != 0) {
board[pieceRow + r][pieceCol + c] = 1;
}
}
}
private void clearFullRows() {
List<int[]> kept = new ArrayList<>();
for (int[] row : board) {
if (IntStream.of(row).anyMatch(cell -> cell == 0)) {
kept.add(row);
}
}
int cleared = rows - kept.size();
linesCleared += cleared;
for (int i = 0; i < cleared; i++) {
kept.add(0, new int[cols]);
}
board = kept.toArray(new int[0][]);
}
private void spawn() {
shape = pieceSupply.get().shape();
pieceRow = 0;
pieceCol = (cols - shape[0].length) / 2;
if (!fits(shape, pieceRow, pieceCol)) {
status = Status.GAME_OVER; // the well is full to the brim
}
}
private void requirePlaying() {
if (status != Status.PLAYING) {
throw new IllegalStateException("game over");
}
}
public Status status() {
return status;
}
public int linesCleared() {
return linesCleared;
}
public int cellAt(int r, int c) {
return board[r][c];
}
}A scripted run — possible because the piece supply is injected:
Iterator<Tetromino> script = List.of(Tetromino.O, Tetromino.O, Tetromino.I).iterator();
Tetris game = new Tetris(6, 4, script::next); // a tiny 6×4 well
game.moveLeft(); // O slides to the wall; another left → false, a non-event
game.hardDrop(); // O settles bottom-left
game.moveRight();
game.hardDrop(); // second O settles bottom-right…
// …the bottom TWO rows are full → both vanish
// game.linesCleared() == 2, and the well is empty againThe interview corner
Clarify before you code: Simple rotation or SRS (the Super Rotation System — the modern Tetris standard) with wall kicks? Hold piece and next-preview in scope? Who owns the timer — the game or the UI loop?
The follow-up ladder:
- "Pieces should be fairly distributed." The 7-bag: deal each set of seven in shuffled batches — a
Supplierimplementation, zero game changes. - "Add wall kicks."
rotate()tries shifted candidates (±1, ±2 columns) through the samefits(); real SRS is just a data table of those offsets. - "Show the ghost piece." Simulate the drop on a copy — a read-only
fitswalk; rendering data, no new state. - "Hold + preview." One buffer slot plus peeking the supply queue; both are features around the engine, which is the sign the engine is right.
- "Two-player garbage rows." Rows insert from the bottom with one gap — pure board surgery, possible only because the falling piece was never in the board.
Mistakes that fail the round: writing the piece into the grid and erasing it every frame; 28 hand-drawn orientations; embedding the gravity timer inside the game class (expose tick(), let the loop own time).
Where to go from here
Pocket version: the piece floats, every action is propose-check-commit through one fits(), rotation is a formula, clears are a filter, and the piece supply is injected.
- Add the 7-bag — real Tetris deals each set of seven pieces in shuffled batches; it's a
Supplierimplementation, twenty lines, no game changes. - Add wall kicks — two extra
tryMovecalls insiderotate(). Feel how cheap features are when the validator is centralized. - Add scoring + levels — clears feed a score table (data, not ifs); levels just shrink the tick interval, which lives outside this class entirely.
- Next in the queue: Chess — where, for the first time in this series, the pieces genuinely behave differently, and polymorphism finally earns its salary.
Forty years of falling blocks, and underneath: one question, asked politely, about whether a cutout fits a photo.