Campo Minado parece simples à primeira vista — é um grid com números e minas. Mas implementá-lo
do zero revela várias decisões técnicas interessantes: como distribuir minas de forma verdadeiramente
aleatória? Como garantir que o primeiro clique nunca seja uma mina? Como implementar o reveal em
cascata de células vazias sem travar a UI? Neste artigo mostro como resolvi cada uma dessas
questões no Campo Minado Evolution.
A estrutura de dados: modelando uma célula
Cada posição do tabuleiro é representada por um objeto Celula com todo o estado
necessário para renderização e lógica do jogo:
class Celula {
final int linha;
final int coluna;
bool temMina;
bool revelada;
bool marcada; // bandeirinha do jogador
int minasAdjacentes;
Celula({required this.linha, required this.coluna})
: temMina = false,
revelada = false,
marcada = false,
minasAdjacentes = 0;
}
O tabuleiro inteiro é uma lista bidimensional inicializada com células limpas. Nenhuma mina
é colocada na criação — isso é intencional, como veremos em seguida.
Distribuindo as minas: o problema do primeiro clique
Uma regra clássica do Campo Minado: o primeiro clique nunca pode explodir uma mina.
Isso significa que as minas precisam ser distribuídas após o primeiro clique, excluindo
a célula clicada e todos os seus vizinhos do sorteio.
void distribuirMinas(int linhaSegura, int colunaSegura) {
final rand = Random();
int minasColocadas = 0;
while (minasColocadas < totalMinas) {
int l = rand.nextInt(linhas);
int c = rand.nextInt(colunas);
// Exclui a célula clicada e os 8 vizinhos ao redor dela
bool ehZonaSegura =
(l - linhaSegura).abs() <= 1 && (c - colunaSegura).abs() <= 1;
if (!ehZonaSegura && !tabuleiro[l][c].temMina) {
tabuleiro[l][c].temMina = true;
minasColocadas++;
}
}
}
Esse loop continua sorteando posições até preencher todas as minas. A zona de exclusão
de 3×3 ao redor do primeiro clique garante que o jogador sempre começa com algum espaço
para raciocinar — sem explosão imediata na primeira jogada.
Calculando os números adjacentes
Após distribuir as minas, percorremos todo o tabuleiro e calculamos quantas minas existem
nos 8 vizinhos de cada célula que não é mina:
void calcularAdjacentes() {
for (int l = 0; l < linhas; l++) {
for (int c = 0; c < colunas; c++) {
if (!tabuleiro[l][c].temMina) {
tabuleiro[l][c].minasAdjacentes = _contarVizinhos(l, c);
}
}
}
}
int _contarVizinhos(int l, int c) {
int count = 0;
for (int dl = -1; dl <= 1; dl++) {
for (int dc = -1; dc <= 1; dc++) {
if (dl == 0 && dc == 0) continue; // ignora a própria célula
int nl = l + dl;
int nc = c + dc;
if (nl >= 0 && nl < linhas && nc >= 0 && nc < colunas) {
if (tabuleiro[nl][nc].temMina) count++;
}
}
}
return count;
}
O reveal em cascata: flood fill recursivo
Quando o jogador clica em uma célula com minasAdjacentes == 0, todas as células
vazias conectadas devem ser reveladas automaticamente — e os números nas bordas dessa região
também. Isso é um algoritmo de flood fill (preenchimento por inundação),
o mesmo usado em ferramentas de pintura tipo balde.
void revelar(int l, int c) {
// Limites do tabuleiro
if (l < 0 || l >= linhas || c < 0 || c >= colunas) return;
final celula = tabuleiro[l][c];
// Não revela células já reveladas ou marcadas com bandeira
if (celula.revelada || celula.marcada) return;
celula.revelada = true;
// Se a célula não tem minas ao redor, revela os 8 vizinhos recursivamente
if (!celula.temMina && celula.minasAdjacentes == 0) {
for (int dl = -1; dl <= 1; dl++) {
for (int dc = -1; dc <= 1; dc++) {
if (dl == 0 && dc == 0) continue;
revelar(l + dl, c + dc);
}
}
}
}
A recursão para naturalmente quando encontra células já reveladas, marcadas com bandeira,
ou células com número maior que zero (bordas da região vazia). Em tabuleiros grandes, a
pilha de chamadas pode crescer. Para tabuleiros muito grandes, vale converter para uma
abordagem iterativa com uma fila (Queue).
Renderizando o tabuleiro com Flutter
O tabuleiro é renderizado com GridView.builder com um delegate de contagem
fixa de colunas. Cada célula é um GestureDetector que responde ao toque
simples (revelar) e ao toque longo (colocar bandeira):
GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: colunas,
childAspectRatio: 1.0,
),
itemCount: linhas * colunas,
itemBuilder: (context, index) {
final l = index ~/ colunas;
final c = index % colunas;
final celula = tabuleiro[l][c];
return GestureDetector(
onTap: () => controller.revelar(l, c),
onLongPress: () => controller.alternarBandeira(l, c),
child: CelulaWidget(celula: celula),
);
},
)
O CelulaWidget recebe a célula e decide o que exibir: célula coberta, bandeira,
número colorido ou explosão. As cores dos números seguem a convenção clássica do Campo Minado:
1 azul, 2 verde, 3 vermelho, etc.
Condições de vitória e derrota
As duas condições são verificadas após cada jogada:
// Derrota: o jogador revelou uma mina
bool verificarDerrota(int l, int c) => tabuleiro[l][c].temMina;
// Vitória: todas as células SEM mina foram reveladas
bool verificarVitoria() {
return tabuleiro
.expand((row) => row)
.where((c) => !c.temMina)
.every((c) => c.revelada);
}
Na vitória, todas as minas restantes são automaticamente marcadas com bandeira e o cronômetro
para. Na derrota, todas as minas são reveladas com animação e a sessão termina.
Implementar Campo Minado em Flutter é um exercício excelente porque combina algoritmos clássicos
(flood fill, geração aleatória com restrição) com UI reativa. O resultado é um jogo que roda
suavemente a 60fps porque toda a lógica vive fora da árvore de widgets — o estado do tabuleiro
é gerenciado no controller e a UI só renderiza o que mudou.
Experimente o Campo Minado Evolution gratuitamente no Google Play:
Minesweeper looks simple at first glance — it's a grid of numbers and mines. But implementing
it from scratch reveals several interesting technical decisions: how do you distribute mines
in a truly random way? How do you guarantee the first click is never a mine? How do you
implement the cascading reveal of empty cells without freezing the UI? In this article I show
how I solved each of these questions in Campo Minado Evolution.
The data structure: modeling a cell
Each board position is represented by a Celula object holding all the state
needed for rendering and game logic:
class Celula {
final int linha;
final int coluna;
bool temMina;
bool revelada;
bool marcada; // player's flag
int minasAdjacentes;
Celula({required this.linha, required this.coluna})
: temMina = false,
revelada = false,
marcada = false,
minasAdjacentes = 0;
}
The entire board is a two-dimensional list initialized with clean cells. No mine is placed
at creation time — that's intentional, as we'll see next.
A classic Minesweeper rule: the first click can never explode a mine. That
means mines need to be distributed after the first click, excluding the clicked cell
and all of its neighbors from the draw.
void distribuirMinas(int linhaSegura, int colunaSegura) {
final rand = Random();
int minasColocadas = 0;
while (minasColocadas < totalMinas) {
int l = rand.nextInt(linhas);
int c = rand.nextInt(colunas);
// Excludes the clicked cell and its 8 surrounding neighbors
bool ehZonaSegura =
(l - linhaSegura).abs() <= 1 && (c - colunaSegura).abs() <= 1;
if (!ehZonaSegura && !tabuleiro[l][c].temMina) {
tabuleiro[l][c].temMina = true;
minasColocadas++;
}
}
}
This loop keeps drawing positions until every mine has been placed. The 3×3 exclusion zone
around the first click guarantees the player always starts with some room to reason — no
instant explosion on the first move.
Calculating the adjacent numbers
After distributing the mines, we walk the entire board and calculate how many mines exist
among the 8 neighbors of every non-mine cell:
void calcularAdjacentes() {
for (int l = 0; l < linhas; l++) {
for (int c = 0; c < colunas; c++) {
if (!tabuleiro[l][c].temMina) {
tabuleiro[l][c].minasAdjacentes = _contarVizinhos(l, c);
}
}
}
}
int _contarVizinhos(int l, int c) {
int count = 0;
for (int dl = -1; dl <= 1; dl++) {
for (int dc = -1; dc <= 1; dc++) {
if (dl == 0 && dc == 0) continue; // skip the cell itself
int nl = l + dl;
int nc = c + dc;
if (nl >= 0 && nl < linhas && nc >= 0 && nc < colunas) {
if (tabuleiro[nl][nc].temMina) count++;
}
}
}
return count;
}
The cascading reveal: recursive flood fill
When the player clicks a cell with minasAdjacentes == 0, every connected empty
cell must be revealed automatically — along with the numbers at the edges of that region.
This is a flood fill algorithm, the same technique used by bucket-fill tools
in paint programs.
void revelar(int l, int c) {
// Board bounds
if (l < 0 || l >= linhas || c < 0 || c >= colunas) return;
final celula = tabuleiro[l][c];
// Don't reveal cells that are already revealed or flagged
if (celula.revelada || celula.marcada) return;
celula.revelada = true;
// If the cell has no mines around it, recursively reveal its 8 neighbors
if (!celula.temMina && celula.minasAdjacentes == 0) {
for (int dl = -1; dl <= 1; dl++) {
for (int dc = -1; dc <= 1; dc++) {
if (dl == 0 && dc == 0) continue;
revelar(l + dl, c + dc);
}
}
}
}
The recursion naturally stops when it hits cells that are already revealed, flagged, or cells
with a number greater than zero (the edges of the empty region). On large boards, the call
stack can grow substantially. For very large boards, it's worth converting this to an
iterative approach using a queue (Queue).
Rendering the board with Flutter
The board is rendered with GridView.builder using a fixed cross-axis-count
delegate. Each cell is a GestureDetector that responds to a simple tap (reveal)
and a long press (place a flag):
GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: colunas,
childAspectRatio: 1.0,
),
itemCount: linhas * colunas,
itemBuilder: (context, index) {
final l = index ~/ colunas;
final c = index % colunas;
final celula = tabuleiro[l][c];
return GestureDetector(
onTap: () => controller.revelar(l, c),
onLongPress: () => controller.alternarBandeira(l, c),
child: CelulaWidget(celula: celula),
);
},
)
The CelulaWidget receives the cell and decides what to display: a covered cell,
a flag, a colored number, or an explosion. The number colors follow the classic Minesweeper
convention: 1 blue, 2 green, 3 red, and so on.
Win and loss conditions
Both conditions are checked after every move:
// Loss: the player revealed a mine
bool verificarDerrota(int l, int c) => tabuleiro[l][c].temMina;
// Win: every cell WITHOUT a mine has been revealed
bool verificarVitoria() {
return tabuleiro
.expand((row) => row)
.where((c) => !c.temMina)
.every((c) => c.revelada);
}
On a win, every remaining mine is automatically flagged and the timer stops. On a loss, every
mine is revealed with an animation and the session ends.
Implementing Minesweeper in Flutter is an excellent exercise because it combines classic
algorithms (flood fill, constrained random generation) with reactive UI. The result is a game
that runs smoothly at 60fps because all the logic lives outside the widget tree — the board
state is managed in the controller, and the UI only re-renders what actually changed.
Try Campo Minado Evolution for free on Google Play:
El Buscaminas parece simple a primera vista — es una cuadrícula con números y minas. Pero
implementarlo desde cero revela varias decisiones técnicas interesantes: ¿cómo distribuir las
minas de forma verdaderamente aleatoria? ¿Cómo garantizar que el primer clic nunca sea una
mina? ¿Cómo implementar el revelado en cascada de celdas vacías sin bloquear la UI? En este
artículo muestro cómo resolví cada una de estas cuestiones en Campo Minado Evolution.
La estructura de datos: modelando una celda
Cada posición del tablero está representada por un objeto Celula con todo el
estado necesario para el renderizado y la lógica del juego:
class Celula {
final int linha;
final int coluna;
bool temMina;
bool revelada;
bool marcada; // bandera del jugador
int minasAdjacentes;
Celula({required this.linha, required this.coluna})
: temMina = false,
revelada = false,
marcada = false,
minasAdjacentes = 0;
}
Todo el tablero es una lista bidimensional inicializada con celdas limpias. No se coloca
ninguna mina en la creación — esto es intencional, como veremos a continuación.
Distribuyendo las minas: el problema del primer clic
Una regla clásica del Buscaminas: el primer clic nunca puede detonar una mina.
Esto significa que las minas deben distribuirse después del primer clic, excluyendo
la celda pulsada y todas sus vecinas del sorteo.
void distribuirMinas(int linhaSegura, int colunaSegura) {
final rand = Random();
int minasColocadas = 0;
while (minasColocadas < totalMinas) {
int l = rand.nextInt(linhas);
int c = rand.nextInt(colunas);
// Excluye la celda pulsada y sus 8 vecinas alrededor
bool ehZonaSegura =
(l - linhaSegura).abs() <= 1 && (c - colunaSegura).abs() <= 1;
if (!ehZonaSegura && !tabuleiro[l][c].temMina) {
tabuleiro[l][c].temMina = true;
minasColocadas++;
}
}
}
Este bucle sigue sorteando posiciones hasta colocar todas las minas. La zona de exclusión de
3×3 alrededor del primer clic garantiza que el jugador siempre empiece con algo de espacio
para razonar — sin explosión inmediata en la primera jugada.
Calculando los números adyacentes
Después de distribuir las minas, recorremos todo el tablero y calculamos cuántas minas
existen entre las 8 vecinas de cada celda que no es mina:
void calcularAdjacentes() {
for (int l = 0; l < linhas; l++) {
for (int c = 0; c < colunas; c++) {
if (!tabuleiro[l][c].temMina) {
tabuleiro[l][c].minasAdjacentes = _contarVizinhos(l, c);
}
}
}
}
int _contarVizinhos(int l, int c) {
int count = 0;
for (int dl = -1; dl <= 1; dl++) {
for (int dc = -1; dc <= 1; dc++) {
if (dl == 0 && dc == 0) continue; // ignora la propia celda
int nl = l + dl;
int nc = c + dc;
if (nl >= 0 && nl < linhas && nc >= 0 && nc < colunas) {
if (tabuleiro[nl][nc].temMina) count++;
}
}
}
return count;
}
El revelado en cascada: flood fill recursivo
Cuando el jugador hace clic en una celda con minasAdjacentes == 0, todas las
celdas vacías conectadas deben revelarse automáticamente — junto con los números en los
bordes de esa región. Este es un algoritmo de flood fill (relleno por
inundación), el mismo que usan las herramientas de relleno tipo balde en programas de pintura.
void revelar(int l, int c) {
// Límites del tablero
if (l < 0 || l >= linhas || c < 0 || c >= colunas) return;
final celula = tabuleiro[l][c];
// No revela celdas ya reveladas o marcadas con bandera
if (celula.revelada || celula.marcada) return;
celula.revelada = true;
// Si la celda no tiene minas alrededor, revela las 8 vecinas recursivamente
if (!celula.temMina && celula.minasAdjacentes == 0) {
for (int dl = -1; dl <= 1; dl++) {
for (int dc = -1; dc <= 1; dc++) {
if (dl == 0 && dc == 0) continue;
revelar(l + dl, c + dc);
}
}
}
}
La recursión se detiene de forma natural al encontrar celdas ya reveladas, marcadas con
bandera, o celdas con un número mayor que cero (los bordes de la región vacía). En tableros
grandes, la pila de llamadas puede crecer bastante. Para tableros muy grandes, conviene
convertirlo a un enfoque iterativo con una cola (Queue).
Renderizando el tablero con Flutter
El tablero se renderiza con GridView.builder usando un delegate de conteo fijo
de columnas. Cada celda es un GestureDetector que responde al toque simple
(revelar) y a la pulsación larga (colocar bandera):
GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: colunas,
childAspectRatio: 1.0,
),
itemCount: linhas * colunas,
itemBuilder: (context, index) {
final l = index ~/ colunas;
final c = index % colunas;
final celula = tabuleiro[l][c];
return GestureDetector(
onTap: () => controller.revelar(l, c),
onLongPress: () => controller.alternarBandeira(l, c),
child: CelulaWidget(celula: celula),
);
},
)
El CelulaWidget recibe la celda y decide qué mostrar: celda cubierta, bandera,
número coloreado o explosión. Los colores de los números siguen la convención clásica del
Buscaminas: 1 azul, 2 verde, 3 rojo, etc.
Condiciones de victoria y derrota
Ambas condiciones se verifican después de cada jugada:
// Derrota: el jugador reveló una mina
bool verificarDerrota(int l, int c) => tabuleiro[l][c].temMina;
// Victoria: todas las celdas SIN mina fueron reveladas
bool verificarVitoria() {
return tabuleiro
.expand((row) => row)
.where((c) => !c.temMina)
.every((c) => c.revelada);
}
En la victoria, todas las minas restantes se marcan automáticamente con bandera y el
cronómetro se detiene. En la derrota, todas las minas se revelan con una animación y la
sesión termina.
Implementar el Buscaminas en Flutter es un ejercicio excelente porque combina algoritmos
clásicos (flood fill, generación aleatoria con restricciones) con UI reactiva. El resultado
es un juego que corre con fluidez a 60fps porque toda la lógica vive fuera del árbol de
widgets — el estado del tablero se gestiona en el controller y la UI solo renderiza lo que
realmente cambió.
Prueba Campo Minado Evolution gratis en Google Play: