10. Mines¶
Our first game is a classic: a field of hidden cells, some with mines under them. Open every cell without a mine and you win; open a mine and you lose. Numbers tell you how many mines touch a cell, and from them you deduce where the mines are.

Mines is a perfect first game: no animation, no timing, no physics. The program waits for a click, reacts, and waits again. That lets us concentrate on the foundations every later game stands on.
What you will learn
- Turning the rules of a game into a model: plain Java classes with tests
- Storing a grid in an array, finding neighbours, and flood fill without recursion
- Random numbers that tests can repeat
- Drawing with Java2D:
paintComponent, shapes, colours from the desktop theme - Handling the mouse: press, drag, release, two buttons at once
- Model-View-Presenter in a real game, tested without a screen
- Measuring time in a testable way
- High scores with the engine
The complete game is in amade-mines-game. Open it next to this chapter.
The rules¶
Before writing code, write down the rules precisely. Every vague sentence here becomes a bug later.
- The field has columns × rows cells. Some hold a mine; the player does not see which.
- Opening a cell without a mine shows how many of its up to eight neighbours hold a mine: a number from 1 to 8, or an empty cell for 0.
- An empty cell opens all its neighbours automatically, and empty ones among them open theirs, and so on. One click can open a large area.
- Opening a mine loses the game. All mines are shown.
- When every cell without a mine is open, the game is won.
- A cell can carry a flag ("there is a mine here") or a question mark ("not sure"). Flagged cells cannot be opened by accident. The counter shows mines minus flags.
- Chord: clicking an open number that has exactly as many flags around it as its number opens all its other neighbours at once.
- The first click is always safe and always opens an area.
- Three sizes: Beginner 9 × 9 with 10 mines, Intermediate 16 × 16 with 40, Expert 30 × 16 with 99.
Rule 8 is the interesting one. How can the first click be safe if the mines are already there? The trick: they are not. We lay the mines after the first click, around it.
Step 1: The field in memory¶
A grid suggests a two-dimensional array, boolean[rows][columns]. Mines uses a one-dimensional array instead, and computes the index from column and row:
One index per cell makes many things simpler: a cell fits into one int, a list of cells is an int[], and a work list for the flood fill is a queue of numbers. The way back is column = index % columns and row = index / columns.
The Board class keeps four arrays of the same length, one value per cell: is there a mine, is the cell open, which mark does it carry (NONE, FLAG or QUESTION), and how many mines touch it. Then a little bookkeeping: the state (READY, PLAYING, WON, LOST), how many cells are open, how many flags are set, and which mine went off:
/** One entry per cell, at index {@code row * columns + column}. */
private final boolean[] mine;
private final boolean[] open;
private final Mark[] marks;
/** Mines among the neighbours, 0 to 8. */
private final int[] counts;
private State state = State.READY;
private int opened;
private int flags;
/** The mine that was opened, -1 while none was. */
private int exploded = -1;
Neighbours¶
Almost every rule talks about the eight neighbours of a cell. Cells at the edge have fewer: a corner has three. One method finds them for every cell:
private int[] neighbours(int i) {
int c = i % columns;
int r = i / columns;
int[] result = new int[8];
int size = 0;
for (int dr = -1; dr <= 1; dr++) {
for (int dc = -1; dc <= 1; dc++) {
if ((dr != 0 || dc != 0) && contains(c + dc, r + dr)) result[size++] = (r + dr) * columns + c + dc;
}
}
return Arrays.copyOf(result, size);
}
The two loops visit the 3 × 3 square around the cell, skip the cell itself (dr != 0 || dc != 0) and anything outside the field (contains). You will write this pattern again in many games: Bomberman's explosions, the ghosts in Maze and the units in Sands all ask for their neighbours.
Step 2: Laying the mines¶
Laying 10 mines on 81 cells sounds easy: pick a random cell, put a mine there if it has none, repeat until there are 10. Many programs do exactly that. It works, but it has two problems. On a nearly full field the loop keeps picking cells that are already taken. And the rule "not on or around the first cell" turns into more retries.
A cleaner way is to shuffle. Make a list of all cells that may receive a mine, shuffle it, and take the first ten. We do not even need to shuffle all of it: the Fisher–Yates shuffle can stop after the first ten places.
private void layMines(int first) {
int cells = columns * rows;
boolean[] blocked = new boolean[cells];
blocked[first] = true;
// Keep the neighbours free as well unless the field is so full that the mines would not fit.
int[] around = neighbours(first);
if (mines <= cells - 1 - around.length) for (int n : around) blocked[n] = true;
int[] candidates = new int[cells];
int size = 0;
for (int i = 0; i < cells; i++) if (!blocked[i]) candidates[size++] = i;
// Partial Fisher–Yates shuffle: the first `mines` candidates become mines.
for (int k = 0; k < mines; k++) {
int j = k + random.nextInt(size - k);
int swap = candidates[k];
candidates[k] = candidates[j];
candidates[j] = swap;
mine[candidates[k]] = true;
}
countNeighbours();
}
Read the loop slowly. In round k, a random position j from k to the end is chosen, and the candidates at k and j swap places. Candidate k becomes a mine. Every free cell has the same chance, and no cell is drawn twice. When the field is too full to keep the neighbours free (try 8 mines on 3 × 3), only the first cell itself stays free.
Random numbers that tests can repeat
Board does not create its own Random. It receives a RandomGenerator in the constructor. The game passes a truly random one. A test passes new SplittableRandom(7), a generator with a fixed seed, and gets the same field on every run. A test that fails once fails every time, and you can find the bug.
Step 3: Opening a cell¶
Here is the method behind a left click:
public boolean open(int column, int row) {
if (finished() || !contains(column, row)) return false;
int i = index(column, row);
if (open[i] || marks[i] == Mark.FLAG) return false;
if (state == State.READY) {
layMines(i);
state = State.PLAYING;
}
if (mine[i]) {
exploded = i;
state = State.LOST;
return true;
}
flood(i);
if (opened == columns * rows - mines) win();
return true;
}
The order matters:
- Nothing happens after the end of the game, outside the field, on open or flagged cells. The method returns
false, "nothing changed", and the caller does not even redraw. - On the first click (
READY) the mines are laid around this cell. - A mine loses.
explodedremembers which one, so it can be drawn red. - Otherwise
floodopens the cell and, if it is empty, the area around it. - If every cell without a mine is open, the game is won.
Flood fill without recursion¶
Rule 3, the area opening, is a flood fill, like the paint bucket of a drawing program. The obvious version is recursive: open the cell, and if it is empty, call yourself for every neighbour.
The recursive version has a hidden danger: every call that has not finished yet takes a place on the call stack, which is limited. On a 30 × 16 field the open area can be hundreds of cells deep; on a larger custom field the program would end with a StackOverflowError. The robust version keeps its own list of cells still to visit:
/** Opens the cell and, from every empty cell reached, its neighbours; without recursion. */
private void flood(int start) {
Deque<Integer> pending = new ArrayDeque<>();
pending.push(start);
while (!pending.isEmpty()) {
int i = pending.pop();
if (open[i] || mine[i] || marks[i] == Mark.FLAG) continue;
open[i] = true;
opened++;
if (marks[i] == Mark.QUESTION) marks[i] = Mark.NONE;
if (counts[i] == 0) for (int n : neighbours(i)) if (!open[n]) pending.push(n);
}
}
pending is a stack we manage ourselves, an ArrayDeque that grows as needed. Take a cell; skip it if it is already open, a mine or flagged; otherwise open it, and if it shows 0, put its closed neighbours on the stack. When the stack is empty, the area is open.
Behind the scenes
This "work list" pattern returns in part II and III: breadth-first search in Maze is the same loop with a queue instead of a stack, and A* in Sands is the same loop with a priority queue.
Step 4: What a cell shows¶
The view will need to know what to draw for each cell. Should it look at mine, open, marks and state itself? No: then the rules of showing (a wrong flag is crossed out after losing, a mine you did not flag appears) would end up in the drawing code. Instead, the model answers one question per cell with an enum:
public Look look(int column, int row) {
int i = index(column, row);
if (i == exploded) return Look.EXPLODED;
if (open[i]) return Look.OPEN;
if (state == State.LOST) {
if (mine[i] && marks[i] != Mark.FLAG) return Look.MINE;
if (!mine[i] && marks[i] == Mark.FLAG) return Look.WRONG_FLAG;
}
return switch (marks[i]) {
case FLAG -> Look.FLAG;
case QUESTION -> Look.QUESTION;
case NONE -> Look.HIDDEN;
};
}
Look has seven values: HIDDEN, FLAG, QUESTION, OPEN, MINE, EXPLODED, WRONG_FLAG. For OPEN cells the view asks count(column, row) for the number. The drawing code becomes a simple switch, and the rules stay testable.
Step 5: Testing the rules¶
The model is complete, and there is no window yet. This is the best moment for tests. To make them readable, Board has a small factory that builds a field from text, * for a mine:
@Test
void anEmptyCellOpensItsArea() {
Board board = Board.of(
"....",
"....",
"...*",
"....");
board.open(0, 0);
assertTrue(board.isOpen(3, 0));
assertTrue(board.isOpen(2, 2));
assertEquals(1, board.count(2, 2));
assertFalse(board.isOpen(3, 3), "behind the numbers");
assertEquals(Board.State.PLAYING, board.state());
board.open(3, 3);
assertEquals(Board.State.WON, board.state());
assertEquals(Look.FLAG, board.look(3, 2), "the mines get their flags");
assertEquals(0, board.minesLeft());
}
The field reads like a picture, and the test reads like the rules. The safe first click is tested on real random fields, twenty times over:
@RepeatedTest(20)
void theFirstClickIsSafeAndOpensAnArea() {
RandomGenerator random = new SplittableRandom();
Board board = new Board(Difficulty.EXPERT, random);
assertEquals(Board.State.READY, board.state());
int c = random.nextInt(board.columns());
int r = random.nextInt(board.rows());
assertTrue(board.open(c, r));
assertEquals(Board.State.PLAYING, board.state());
assertEquals(0, board.count(c, r), "no mine around the first cell");
assertTrue(board.isOpen(c, r));
}
And the marks, including the counter going below zero when you set more flags than there are mines:
@Test
void marksCycleAndCountFlags() {
Board board = Board.of("*..", "...");
assertEquals(1, board.minesLeft());
board.cycleMark(1, 0, true);
assertEquals(Look.FLAG, board.look(1, 0));
assertEquals(0, board.minesLeft());
board.cycleMark(2, 0, true);
assertEquals(-1, board.minesLeft(), "more flags than mines");
board.cycleMark(1, 0, true);
assertEquals(Look.QUESTION, board.look(1, 0));
assertEquals(0, board.minesLeft());
board.cycleMark(1, 0, true);
assertEquals(Look.HIDDEN, board.look(1, 0));
}
Run them with ./gradlew test --tests '*BoardTest'. They need no screen and finish in well under a second.
Pitfall: testing through the window
It is tempting to test a game by clicking through it. Those tests are slow, need a display and break whenever the layout changes. Test the rules in the model and the decisions in the presenter. Keep the few window tests for checking that clicks arrive at all.
Step 6: Drawing the field¶
Now the field gets a face. In Swing, a component that draws itself extends JComponent and overrides paintComponent. Swing calls it whenever the component must be drawn: when the window appears, when it was covered, or when you call repaint(). You never call paintComponent yourself.
protected void paintComponent(Graphics g) {
DesktopTheme theme = AmadeUI.theme();
Graphics2D g2 = (Graphics2D) g.create();
g2.setColor(theme.base());
g2.fillRect(0, 0, getWidth(), getHeight());
if (board == null) {
g2.dispose();
return;
}
Set<Integer> down = pressedCells();
Font numberFont = getFont().deriveFont(Font.BOLD, cellSize * 0.62f);
for (int r = 0; r < board.rows(); r++) {
for (int c = 0; c < board.columns(); c++) {
paintCell(g2, theme, c, r, down.contains(r * board.columns() + c), numberFont);
}
}
g2.dispose();
}
The Graphics object is a pen for this component. Its coordinates start at the top left corner, x to the right and y downwards, in pixels. g.create() makes a copy we may change freely (colour, font, smoothing) and must dispose() at the end, so the next component gets an unchanged pen.
Each cell is drawn at x = column × cellSize, y = row × cellSize. Closed cells get a raised 3D border from amade-commons (AmadeUI.paintBevel), open cells a flat background with a thin line. The colours come from AmadeUI.theme(), the desktop's current colour theme. When the user picks another theme in the Style Manager, amade-commons repaints all windows, paintComponent runs again, and the field takes the new colours without any code of ours.
Cells that grow with the text
cellSize is not a fixed number of pixels. The window computes it from the font size, about 1.7 times the height of the text. On a large screen with a large font, the field grows with it.
Drawing with shapes¶
Mines, flags and faces are not images and not emoji characters. They are drawn from simple shapes, because an emoji font may not be installed and images do not scale. Here is the mine: four lines for the spikes, a filled circle and a small white circle as a reflection:
private void paintMine(Graphics2D g, int x, int y) {
float cx = x + cellSize / 2f + 0.5f;
float cy = y + cellSize / 2f + 0.5f;
float radius = cellSize * 0.24f;
float spike = cellSize * 0.36f;
g.setColor(Color.BLACK);
g.setStroke(new BasicStroke(Math.max(1.5f, cellSize / 12f), BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
g.draw(new Line2D.Float(cx - spike, cy, cx + spike, cy));
g.draw(new Line2D.Float(cx, cy - spike, cx, cy + spike));
float d = spike * 0.72f;
g.draw(new Line2D.Float(cx - d, cy - d, cx + d, cy + d));
g.draw(new Line2D.Float(cx - d, cy + d, cx + d, cy - d));
g.fill(new Ellipse2D.Float(cx - radius, cy - radius, radius * 2, radius * 2));
g.setColor(Color.WHITE);
float glare = radius * 0.55f;
g.fill(new Ellipse2D.Float(cx - radius * 0.6f, cy - radius * 0.6f, glare, glare));
}
Line2D.Float and Ellipse2D.Float take float coordinates, so the shape is exact at every size. Before drawing, the code switches on antialiasing (RenderingHints.KEY_ANTIALIASING), which smooths the edges of round shapes. All sizes are fractions of cellSize, so the mine scales with the cell.
The seven-segment digits of the counters (Counter) and the face on the button (FaceIcon) are drawn the same way. Look at them as exercises in reading Java2D code.
Step 7: The mouse¶
Swing reports mouse buttons as events: mousePressed, mouseReleased, mouseDragged. Mines reacts like the original:
- Left button: the cell under the mouse looks pressed while the button is down. It opens on release, and only if the mouse is still over a cell. Dragging away is a way to change your mind.
- Right button: cycles the mark immediately on press.
- Middle button, or left and right together: chord. The neighbours look pressed while the buttons are down.
The first job is turning pixels into a cell, by dividing by the cell size:
/** The cell under a point; a point outside the field gives a cell outside the board. */
public Point cellAt(Point p) {
return new Point(Math.floorDiv(p.x, cellSize), Math.floorDiv(p.y, cellSize));
}
Math.floorDiv instead of / matters for points left of or above the field: -5 / 20 is 0 in Java, which would wrongly be the first column, while floorDiv(-5, 20) is -1, outside. The press and release handling:
private void pressedAt(MouseEvent e) {
if (board == null || board.finished()) return;
Point cell = cellAt(e.getPoint());
int down = e.getModifiersEx();
boolean left = (down & InputEvent.BUTTON1_DOWN_MASK) != 0;
boolean right = (down & InputEvent.BUTTON3_DOWN_MASK) != 0;
if (SwingUtilities.isMiddleMouseButton(e) || (left && right)) {
chording = true;
pressed = cell;
listener.pressing(true);
} else if (SwingUtilities.isLeftMouseButton(e)) {
pressed = cell;
listener.pressing(true);
} else if (SwingUtilities.isRightMouseButton(e) && board.contains(cell.x, cell.y)) {
listener.mark(cell.x, cell.y);
}
repaint();
}
private void releasedAt(MouseEvent e) {
if (board == null) return;
int stillDown = e.getModifiersEx() & (InputEvent.BUTTON1_DOWN_MASK | InputEvent.BUTTON2_DOWN_MASK
| InputEvent.BUTTON3_DOWN_MASK);
Point cell = cellAt(e.getPoint());
if (chording) {
chording = false;
pressed = null;
swallowRelease = stillDown != 0;
if (board.contains(cell.x, cell.y)) listener.chord(cell.x, cell.y);
} else if (swallowRelease) {
swallowRelease = stillDown != 0;
} else if (SwingUtilities.isLeftMouseButton(e) && pressed != null) {
pressed = null;
if (board.contains(cell.x, cell.y)) listener.open(cell.x, cell.y);
}
if (stillDown == 0) listener.pressing(false);
repaint();
}
Two details are worth a closer look. First, getModifiersEx() tells which buttons are down at the moment of the event. That is how pressing the second button while the first is held becomes a chord. Second, after a chord the other button is still down; when it is released, it must not open a cell. swallowRelease remembers that.
Notice what BoardView does not do: it never calls board.open. It reports the gesture through its Listener interface (open, chord, mark, pressing), and someone else decides what that means. That someone is the presenter.
Step 8: The presenter runs the game¶
Chapter 2 introduced Model-View-Presenter. Here is how the game uses it. The presenter starts a new game like this:
/** A new field of the current size (menu, F2 or the face). */
public void newGame() {
clock.reset();
view.setTicking(false);
board = new Board(difficulty, random);
view.showBoard(board);
update();
}
It receives the gestures of the field and plays them on the model. The first successful click also starts the clock:
@Override
public void open(int column, int row) {
boolean first = board.state() == Board.State.READY;
if (board.open(column, row) && first) {
clock.start();
view.setTicking(true);
}
update();
}
@Override
public void chord(int column, int row) {
board.chord(column, row);
update();
}
@Override
public void mark(int column, int row) {
board.cycleMark(column, row, questionMarks);
update();
}
@Override
public void pressing(boolean down) {
if (!board.finished()) view.showFace(down ? Face.SURPRISED : Face.SMILE);
}
After each move, update() brings the counters, the face and the field up to date, stops the clock when the game ended, and records a win (you saw it in chapter 2).
Time you can test¶
The clock is a small model class of its own. Mines wrote it in its domain package; when Solitaire needed the same clock, it moved into the engine, where you find it today:
public final class GameClock {
private final LongSupplier nanoTime;
private long started = -1;
private long stopped = -1;
public GameClock(LongSupplier nanoTime) {
this.nanoTime = nanoTime;
}
public GameClock() {
this(System::nanoTime);
}
public void reset() {
started = -1;
stopped = -1;
}
public void start() {
if (started < 0) started = nanoTime.getAsLong();
}
public void stop() {
if (started >= 0 && stopped < 0) stopped = nanoTime.getAsLong();
}
public boolean running() {
return started >= 0 && stopped < 0;
}
public long millis() {
if (started < 0) return 0;
long end = stopped >= 0 ? stopped : nanoTime.getAsLong();
return (end - started) / 1_000_000;
}
/**
* Seconds as games show them and high scores record them: a started second counts, so the clock shows 1
* right after the first move.
*/
public int seconds() {
if (started < 0) return 0;
return (int) Math.min(Integer.MAX_VALUE, millis() / 1000 + 1);
}
}
It does not read the system time directly. It asks a LongSupplier, which the game fills with System::nanoTime and a test with a number it controls. System.nanoTime() is the right source for durations: unlike the wall clock, it never jumps when the computer adjusts its time.
Who shows the seconds while the player thinks? The presenter asks the view to start a timer (setTicking(true)). The window uses a Swing Timer, which calls presenter.tick() five times a second on the event thread. The presenter reads the clock and calls showTime. The timer belongs to the view because it is Swing; the decision when to run it belongs to the presenter.
@Test
void theFirstClickStartsTheClock() {
presenter.open(4, 4);
assertEquals(Board.State.PLAYING, view.board.state());
assertTrue(view.ticking);
assertEquals(1, view.seconds);
now[0] += 2_000_000_000L;
presenter.tick();
assertEquals(3, view.seconds);
}
Step 9: The window¶
MinesWindow is a JFrame that implements MinesView. Its constructor builds the layout:
- top: mine counter, face button, clock, in a sunken frame;
- centre: the field;
- menu bar: Game and Help.
It forwards the gestures of the field to the presenter:
boardView = new BoardView(null, cellSize, new BoardView.Listener() {
@Override
public void open(int column, int row) {
presenter.open(column, row);
}
@Override
public void chord(int column, int row) {
presenter.chord(column, row);
}
@Override
public void mark(int column, int row) {
presenter.mark(column, row);
}
@Override
public void pressing(boolean down) {
presenter.pressing(down);
}
});
Each MinesView method is a few lines: showMinesLeft sets the counter, showFace changes the icon and repaints the button, showBoard gives the field its new board and calls pack(), so the window takes the size of the new field.
Why does the window not create the presenter itself? Because each needs the other: the presenter talks to the view, the view forwards to the presenter. Guice creates both, and bind(presenter) connects them before the game starts. You saw this in AmadeMinesApplication in chapter 2.
Step 10: High scores¶
The best ten times per field size are kept with the engine's HighScores. The game's Guice module creates it with the game's name (mines) and the information that lower is better:
/** Best times per field size in {@code ~/.local/share/amadeos/games/mines}. */
@Provides
@Singleton
HighScores highScores(XdgDirectories xdg, AppInfo app) {
return HighScores.of(xdg, app.configName(), Ranking.LOWER_WINS);
}
Each field size is one table, a file named after the size: beginner.scores, intermediate.scores, expert.scores.
After a win, qualifies(table, seconds) tells whether the time enters the table. If it does, the presenter asks the view for the player's name, add stores the entry and returns its place, and the view shows the table with the new entry highlighted. The window uses the engine's HighScoreDialog for both. The question offers the name typed last time, and the table has one tab per field size and a Reset button.
The score files are plain text, one line per entry:
Why high scores are in the engine
High scores went into the engine with this very first game, because it was already clear that every game needs them. The same goes for the standard menu entries (New Game with F2, High Scores..., Exit with Ctrl+Q, About). Mines' own entries (field sizes, question marks) stay in the game. The rule of this book: code moves into the engine when a second game needs it, or when it obviously will.
Step 11: Texts and packaging¶
The texts are in five languages, with the checks from chapter 2: a translation test and a test that no two entries of the Game menu share a mnemonic letter. That second test caught a real clash while this game was written: in English, E&xpert and E&xit both wanted the x.
./gradlew deb builds the Debian package with:
- the jar in
/opt/amade-mines-game/; - a start script in
/usr/bin/; - a desktop entry in the menu category Game;LogicGame;
- a 48-pixel icon.
The icon itself was drawn with the same Java2D code as the field.
What moved into the engine¶
| From Mines | Engine package | Why |
|---|---|---|
| High scores, the remembered player name | eu.amadeos.gameengine.score |
Every game has results worth keeping |
| New Game, High Scores, Exit, About | eu.amadeos.gameengine.swing.GameMenus |
Same keys and texts in every game |
| High-score table and name question | eu.amadeos.gameengine.swing.HighScoreDialog |
Same dialogs in every game |
Everything else stays in the game: the field, its drawing, the counters, the face. Solitaire, the next game, showed that one more piece was more general than it looked: the GameClock moved into the engine (eu.amadeos.gameengine.time) as soon as a second game measured time.
Exercises¶
- Custom field. Add a fourth size, Custom, with a dialog for columns, rows and mines. What must
Boardcheck? (Look at its constructor.) Which test do you write first? - Play with the keyboard. Arrow keys move a highlighted cell, space opens,
Fflags. Where does the highlighted cell belong: in the model, the presenter or the view? Argue for your choice. - Statistics. Count games played and won per size, and show the win rate in the high-score dialog. Which package reads and writes the numbers?
- No guessing. Sometimes the numbers do not reveal where the last mines are, and the player must guess. Research "no-guess minesweeper generators". How could
layMinesreject fields that need guessing? - Hexagons. Change the field to hexagonal cells with six neighbours. Which methods change? You will be surprised how few.
In the next game, Solitaire, cards are dragged across the table, fly to their piles and bounce across the screen at the end. Things start to move.