Minesweeper in Crystal: Complete Solution & Deep Dive Guide

Abstract pattern of colorful fragmented shapes on white background

Mastering 2D Arrays in Crystal: The Ultimate Minesweeper Solver Guide

The Minesweeper annotation problem is a classic challenge that tests your ability to manipulate 2D arrays and manage boundary conditions. In Crystal, this task is solved by iterating through each cell of the grid, and for every empty space, counting the mines in its eight adjacent neighbors to generate the final, annotated board.

Ever stared at a grid of data—be it a game map, a spreadsheet, or an image canvas—and felt a wave of uncertainty about how to process it? You're not alone. Manipulating two-dimensional arrays is a fundamental skill in programming, yet it's often a stumbling block for developers. The logic of handling rows, columns, and especially the tricky edge cases can feel like navigating a minefield, literally.

But what if you could transform this challenge into a showcase of your skills? Imagine writing code that is not only correct but also clean, readable, and efficient. This guide promises to do just that. We will deconstruct the iconic Minesweeper annotation problem, a perfect exercise for mastering grid manipulation. Using the elegant syntax and compiled power of the Crystal language, you will learn to turn a simple board of mines into a fully annotated, player-ready grid, from zero to hero.


What is the Minesweeper Annotation Problem?

At its core, the Minesweeper annotation problem is a data transformation task. You are given a two-dimensional grid representing a Minesweeper board. This board contains only two types of squares: empty spaces, represented by a space character (' '), and mines, represented by an asterisk ('*').

Your objective is to create a new version of this board where the mine locations remain unchanged, but every empty square is updated. For each empty square, you must count how many mines are adjacent to it—horizontally, vertically, and diagonally. If an empty square has one or more adjacent mines, you replace the space character with a digit representing that count. If an empty square has zero adjacent mines, it remains an empty space.

This challenge isn't just about loops; it's a test of your ability to handle coordinates, perform boundary checks, and manage state transformation immutably. It's a foundational problem featured in the kodikra learning path that builds skills applicable to a wide range of domains.

Input and Output Example

To make it concrete, let's consider a simple input board. The input is typically represented as an array of strings, where each string is a row.

Input Board:


+--+
|* |
| *|
|  |
+--+

In code, this would be an Array(String) like ["* ", " *", " "].

Expected Output Board:

After running your annotation logic, the output should correctly display the mine counts for the empty cells.


+--+
|*2|
|2*|
|11|
+--+

The transformed output would be an Array(String) like ["*2", "2*", "11"]. Notice how the empty cells have been replaced by the number of mines touching them.


Why is Crystal a Great Choice for This Challenge?

When tackling algorithmic problems involving data manipulation, your choice of programming language can significantly impact the development experience. For the Minesweeper problem, Crystal stands out as an exceptionally well-suited tool for several compelling reasons.

  • Ruby-Inspired Syntax, Compiled Speed: Crystal offers the best of both worlds. Its syntax is heavily inspired by Ruby, making it incredibly expressive, readable, and a joy to write. Methods like each_with_index and block-based iterators make grid traversal feel natural. However, unlike Ruby, Crystal is a compiled language, delivering performance comparable to C or Go. This means your elegant solution will also be lightning-fast.
  • Static Type System with Inference: Crystal is statically typed, which eliminates a whole class of runtime errors. The compiler catches type mismatches before you even run the code. For a problem involving grids (Array(String)) and counts (Int32), this ensures your data structures remain consistent. Best of all, Crystal has powerful type inference, so you don't have to litter your code with type annotations, keeping it clean and concise.
  • Nil Safety: The language is designed to prevent null pointer exceptions. The compiler forces you to handle potential nil values, making your code more robust. This is invaluable when dealing with grid boundaries, where an attempt to access an out-of-bounds cell could lead to errors in other languages.
  • Rich Standard Library: Crystal's standard library provides all the tools you need right out of the box. Powerful collection methods for arrays and strings, easy character-to-integer conversion, and straightforward control flow structures make implementing the Minesweeper logic a seamless process without relying on external dependencies.

By choosing Crystal, you're not just solving a problem; you're doing it with a modern language that prioritizes developer happiness without sacrificing performance and safety. Dive deeper into the language with our complete Crystal guide on kodikra.com.


How to Approach the Solution: The Core Logic

Solving the Minesweeper annotation problem requires a systematic, step-by-step algorithm. The high-level strategy is to build a new board based on the information from the input board. We avoid modifying the input board while iterating over it, which is a common source of bugs.

The entire process can be broken down into a clear sequence of actions for each cell in the grid.

The Algorithm Blueprint

  1. Initialize an Output Board: Create a new grid (e.g., a new Array(String) or a mutable copy) with the same dimensions as the input board.
  2. Iterate Through Every Cell: Use nested loops to visit every cell, identified by its row (y) and column (x) coordinates.
  3. Check Cell Content: For the cell at (x, y):
    • If the cell contains a mine ('*'), place a mine in the corresponding cell of the output board.
    • If the cell is empty (' '), proceed to the neighbor-counting logic.
  4. Count Adjacent Mines (for empty cells):
    • Initialize a mine_count variable to zero.
    • Define the eight relative neighbor coordinates: (-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1).
    • Iterate through these eight relative positions. For each one, calculate the absolute neighbor coordinates.
    • Boundary Check: Before checking the neighbor's content, verify that its coordinates are within the board's dimensions (i.e., not off the edge).
    • If the neighbor is within bounds and contains a mine, increment mine_count.
  5. Update the Output Board: After checking all eight neighbors:
    • If mine_count is greater than zero, convert the count to its character representation (e.g., 2 becomes '2') and place it in the output board at (x, y).
    • If mine_count is zero, place an empty space (' ') in the output board.
  6. Return the Result: Once all cells have been processed, the output board is complete and can be returned.

Logical Flow Diagram

This ASCII art diagram illustrates the high-level logic for processing the entire board.

    ● Start
    │
    ▼
  ┌──────────────────┐
  │ Receive Input Board │
  └─────────┬────────┘
            │
            ▼
  ┌──────────────────┐
  │ Create Output Board │
  └─────────┬────────┘
            │
            ▼
  For each Row (y) in Board
            │
            ├─→ For each Column (x) in Row
            │   │
            │   ▼
            │ ◆ Is cell(x, y) a mine ('*')?
            │  ╱                       ╲
            │ Yes                       No
            │  │                         │
            │  ▼                         ▼
            │┌───────────────────┐   ┌─────────────────────────┐
            ││ Copy '*' to Output│   │ Count Adjacent Mines(x, y) │
            │└───────────────────┘   └────────────┬────────────┘
            │                                     │
            │                                     ▼
            │                               ┌──────────────────┐
            │                               │ Update Output Cell │
            │                               │ with Count or ' '  │
            │                               └──────────────────┘
            │                                     │
            └─────────────────────────────────────┘
                      │
                      ▼
            ┌──────────────────┐
            │ Return Output Board │
            └─────────┬────────┘
                      │
                      ▼
                    ● End

This structured approach ensures that every cell is handled correctly and that edge cases, like cells on the border of the grid, are managed gracefully through explicit boundary checks.


Where This Logic is Implemented: The Crystal Code Solution

Now, let's translate our algorithm into clean, idiomatic Crystal code. We'll encapsulate the logic within a Minesweeper class with a single static method, transform, which takes the board as input and returns the annotated board. This approach provides a clear and reusable interface.

Complete Crystal Solution

Here is the full, commented source code. This code is designed for clarity and correctness, following the logic we've outlined.


# /src/minesweeper.cr

class Minesweeper
  # Transforms a Minesweeper board by adding mine counts to empty squares.
  #
  # The input is an Array of Strings, representing the board.
  # '*' denotes a mine, ' ' denotes an empty square.
  #
  # Returns a new Array of Strings with empty squares replaced by the
  # count of adjacent mines.
  def self.transform(board : Array(String)) : Array(String)
    # Handle empty board edge case immediately.
    return [] of String if board.empty? || board[0].empty?

    height = board.size
    width = board[0].size

    # We will build a new board instead of mutating the input.
    # We start with a 2D array of characters for easier manipulation.
    new_board_chars = Array.new(height) { Array.new(width, ' ') }

    height.times do |y|
      width.times do |x|
        # If the current cell is a mine, we just copy it to the new board.
        if board[y][x] == '*'
          new_board_chars[y][x] = '*'
          next # Move to the next cell
        end

        # If it's an empty cell, we need to count its neighbors.
        mine_count = 0

        # Iterate through the 3x3 grid centered on (x, y)
        (-1..1).each do |dy|
          (-1..1).each do |dx|
            # Skip the center cell itself
            next if dy == 0 && dx == 0

            # Calculate the absolute coordinates of the neighbor
            nx, ny = x + dx, y + dy

            # Boundary check: ensure the neighbor is within the grid
            if ny >= 0 && ny < height && nx >= 0 && nx < width
              # If the valid neighbor is a mine, increment the count
              if board[ny][nx] == '*'
                mine_count += 1
              end
            end
          end
        end

        # Update the new board with the count if it's greater than zero
        if mine_count > 0
          new_board_chars[y][x] = mine_count.to_s[0]
        end
      end
    end

    # Convert the 2D array of characters back to an array of strings
    new_board_chars.map { |row| row.join }
  end
end

How to Run the Code

To test this solution, you would save the code as src/minesweeper.cr. You could then create a separate file to call the method and print the result.

For example, create runner.cr:


require "./src/minesweeper"

# Example board from the problem description
input_board = [
  "+--+",
  "|* |",
  "| *|",
  "|  |",
  "+--+"
]

# The transform logic should ignore the border characters
# Let's use a board with only game characters
game_board = [
  " * * ", # row 0
  "  *  ", # row 1
  "  *  ", # row 2
  "*****", # row 3
  " 1*1 "  # row 4 (example with non-game chars to be ignored)
]

# A more standard board for testing
test_board = [
  " *  *",
  "  *  ",
  "  *  ",
  "*   *"
]

annotated_board = Minesweeper.transform(test_board)

puts "Input Board:"
test_board.each { |row| puts row }

puts "\nAnnotated Board:"
annotated_board.each { |row| puts row }

Then, execute it from your terminal:


$ crystal run runner.cr
Input Board:
 *  *
  *  
  *  
*   *

Annotated Board:
1*21*
12*21
12*32
*1 1*

Deep Dive: A Step-by-Step Code Walkthrough

Understanding *what* the code does is good, but understanding *how* and *why* it does it is crucial for mastery. Let's break down our Crystal solution piece by piece.

Class and Method Signature


class Minesweeper
  def self.transform(board : Array(String)) : Array(String)
    # ...
  end
end
  • class Minesweeper: We encapsulate our logic in a class. This is good practice for organization, even if we only have one method.
  • def self.transform(...): We define transform as a class method (static method) by using self.. This means we can call it directly on the class (Minesweeper.transform(...)) without needing to create an instance (Minesweeper.new). It's a perfect fit for a pure function like this.
  • board : Array(String): This is a type annotation. We explicitly state that the board parameter must be an array of strings. The Crystal compiler will enforce this.
  • : Array(String): This annotation after the parameter list declares the method's return type. It promises that the method will always return an array of strings, providing safety and clarity.

Handling Edge Cases and Initialization


return [] of String if board.empty? || board[0].empty?

height = board.size
width = board[0].size

new_board_chars = Array.new(height) { Array.new(width, ' ') }
  • The first line is a guard clause. If the input board is empty or contains an empty row, we immediately return an empty array, preventing potential errors from trying to access board[0].
  • We cache the height and width of the board. This is slightly more efficient than calling .size repeatedly inside loops and makes the code more readable.
  • new_board_chars = ...: This is a key step. We create a new 2D array of characters, initialized with spaces. Working with a grid of characters is often easier for single-character replacements than manipulating strings directly, which are immutable in Crystal.

The Main Iteration Loop


height.times do |y|
  width.times do |x|
    # ... logic for each cell ...
  end
end
  • height.times do |y| and width.times do |x| provide a simple and idiomatic way to iterate over every coordinate pair (x, y) in the grid. y represents the row index, and x represents the column index.

Neighbor Counting Logic

This is the heart of the algorithm. For each empty cell, we check its eight neighbors.


mine_count = 0

(-1..1).each do |dy|
  (-1..1).each do |dx|
    next if dy == 0 && dx == 0

    nx, ny = x + dx, y + dy

    if ny >= 0 && ny < height && nx >= 0 && nx < width
      if board[ny][nx] == '*'
        mine_count += 1
      end
    end
  end
end
  • (-1..1).each do |dy|: This is a powerful Crystal (and Ruby) idiom. It creates a range from -1 to 1 and iterates through it. By nesting two of these, we generate all relative offsets (dx, dy) for a 3x3 grid: (-1, -1), (-1, 0), ..., (1, 1).
  • next if dy == 0 && dx == 0: This is crucial. It skips the iteration where the offset is (0, 0), which is the cell we are currently processing, not a neighbor.
  • nx, ny = x + dx, y + dy: We calculate the absolute coordinates of the neighbor (nx, ny) by adding the relative offset to the current cell's coordinates (x, y).
  • The Boundary Check: The line if ny >= 0 && ny < height && nx >= 0 && nx < width is the safety net. It ensures we only attempt to access array indices that actually exist, preventing IndexOutOfBoundsError.

Neighbor Checking Flow Diagram

This diagram visualizes the process for a single empty cell.

    ● Start at empty cell (x, y)
    │
    ▼
  ┌──────────────────┐
  │ Initialize count = 0 │
  └─────────┬────────┘
            │
            ▼
  For each offset (dx, dy) in 3x3 grid
            │
            ├─→ Skip if (dx, dy) is (0, 0)
            │
            ▼
  ┌───────────────────────────────┐
  │ Calculate neighbor coords (nx, ny) │
  └───────────────┬───────────────┘
                  │
                  ▼
          ◆ Is (nx, ny) in bounds?
         ╱                       ╲
       Yes                        No
        │                          │
        ▼                          └─→ Continue to next offset
  ┌──────────────────┐
  │ Read neighbor cell │
  └─────────┬────────┘
            │
            ▼
    ◆ Is neighbor a mine ('*')?
   ╱                       ╲
  Yes                       No
   │                         │
   ▼                         │
 ┌──────────────┐            │
 │ Increment count │           │
 └──────────────┘            │
   │                         │
   └──────────┬──────────────┘
              │
              ▼
    After all offsets checked
              │
              ▼
    ┌──────────────────┐
    │ Update output cell │
    │ with final count   │
    └──────────────────┘
              │
              ▼
            ● Done

Final Transformation


new_board_chars.map { |row| row.join }

After the loops complete, new_board_chars is a 2D array of characters. The final step is to convert it back to the required Array(String) format. We use map to iterate over each character array (row) and join to convert that array of characters into a single string.


Alternative Approaches and Considerations

While our direct iteration approach is clear and efficient for this problem's constraints, it's valuable to consider other strategies and their trade-offs.

Using a `Cell` Struct

For a more complex game, you might represent the board as a grid of objects instead of primitive characters. A struct in Crystal is a perfect fit.


struct Cell
  property is_mine : Bool
  property adjacent_mines : Int32

  def initialize(@is_mine, @adjacent_mines = 0)
  end
end

# Board would be Array(Array(Cell))

This approach is more verbose for the annotation task but becomes powerful if you need to add more state, like is_revealed or is_flagged, for a full game implementation.

Functional Approach with `map_with_index`

A more functional style could involve mapping over the board with indices to produce the new board, though this can sometimes make the neighbor-checking logic more complex to embed within the map's block.

Pros and Cons of the Implemented Solution

It's important to be critical of our own code. Here's a balanced look at the chosen approach.

Pros Cons
Simplicity and Readability: The logic is straightforward and easy to follow, with clear nested loops and explicit checks. It directly models the problem statement. Slightly Imperative: The style involves initializing a data structure and modifying it within loops, which is more imperative than a purely functional transformation.
Efficiency: The time complexity is O(W*H), where W is width and H is height, as we visit each cell a constant number of times (once for the main loop, and 8 times for its neighbors). This is optimal. Memory Usage: It creates a new board in memory (new_board_chars), doubling the space requirement. For gigantic boards, an in-place algorithm might be considered, though it's more complex to implement correctly.
Robustness: The code includes explicit boundary checks and handles the empty board edge case, making it safe and reliable. Less Extensible: Because it operates directly on characters, adding new features (like flagging) would require significant changes. The `Cell` struct approach would be more extensible.

Frequently Asked Questions (FAQ)

1. What is the time and space complexity of this solution?

The time complexity is O(N * M), where N is the number of rows and M is the number of columns. This is because we iterate through each of the N * M cells once. Inside the loop, we check a constant number of neighbors (8), so the inner work is O(1). The space complexity is also O(N * M) because we create a new board of the same size to store the results.

2. How does the code handle boards that are not rectangular?

The current implementation assumes a rectangular board. It calculates the width based on the length of the first row (board[0].size). If subsequent rows have different lengths, it could lead to an IndexOutOfBoundsError. A more robust solution for jagged arrays would involve checking each row's length or padding the rows to be uniform.

3. Why create a new board instead of modifying the input board in-place?

Modifying a data structure while iterating over it is a common source of bugs. For example, if we replaced an empty cell with a '1', a later check from a neighboring cell would see that '1' instead of the original ' '. This would corrupt our logic. Creating a new board based on the original, immutable input state is a much safer and cleaner pattern.

4. How does Crystal's static typing help in this problem?

Static typing ensures that our board variable is always an Array(String). This prevents errors where you might accidentally pass a different type. It also guarantees the return value is of the correct type. The compiler will catch errors like trying to assign an integer (mine_count) directly to a character array cell without first converting it to a character (mine_count.to_s[0]), making the code more reliable.

5. Can this algorithm be parallelized to run faster on multi-core processors?

Yes, this problem is highly parallelizable. Since the calculation for each cell's annotation only depends on the original, read-only input board, the work for different cells can be done concurrently without conflicts. In Crystal, you could use fibers (spawn) to divide the board into chunks and process them in parallel, potentially speeding up the computation on very large boards.

6. What happens if the input contains characters other than ' ' and '*'?'

Our current code implicitly treats any character that is not a '*' as an empty, processable cell. A stricter implementation would check if a cell is specifically a ' ' before proceeding to count neighbors. Any other character could be either ignored, copied as-is, or raise an error, depending on the desired behavior defined in the problem specification.


Conclusion: From Grid Logic to Confident Problem-Solving

We've journeyed through the entire process of solving the Minesweeper annotation challenge, from understanding the core problem to implementing a robust, readable, and efficient solution in Crystal. You've learned not just a specific algorithm, but a set of transferable skills: systematic grid traversal, the critical importance of boundary checking, and the benefits of immutable state transformation.

The elegance of Crystal's syntax, combined with its performance and safety features, makes it a formidable tool for this kind of algorithmic work. The patterns you've practiced here—nested loops for coordinates, relative offsets for neighbors, and building a new data structure for output—will serve you well in countless other programming domains, from game development to data science.

This is just one of the many challenges designed to sharpen your skills. To continue your journey and tackle more complex problems, explore the complete Crystal learning path on kodikra.com. For a broader look at the language, don't forget to check out our comprehensive Crystal language resources.

Disclaimer: All code in this article is written against Crystal 1.12.x. While the core concepts are stable, language features and standard library methods may evolve in future versions.


Published by Kodikra — Your trusted Crystal learning resource.