Connect in Crystal: Complete Solution & Deep Dive Guide

a close up of a bunch of ice crystals

Mastering Graph Traversal in Crystal: The Ultimate Guide to Solving the Connect Game

Solving the Connect (Hex) game in Crystal involves modeling the hexagonal board as a graph and applying a pathfinding algorithm like Depth-First Search (DFS). This comprehensive guide demonstrates how to implement a robust DFS to check for a winning path by recursively exploring adjacent, same-colored tiles from one side to the opposite.

Have you ever watched a grandmaster play chess and wondered how they see so many moves ahead? It's not magic; it's pattern recognition and a deep understanding of paths and connections. In the world of programming, we can teach our code to "see" these paths too, whether it's through a maze, a network of servers, or, in our case, across a game board.

The Connect game, a classic strategy challenge, seems simple on the surface but hides a fascinating algorithmic puzzle. It's a perfect testbed for learning one of the most fundamental concepts in computer science: graph traversal. In this guide, we'll demystify this problem and build a complete, elegant solution from scratch using the Crystal programming language, transforming you from a spectator into the architect of a winning strategy.


What is the Connect Game?

Before we can write a single line of code, we must deeply understand the game's mechanics. The Connect game, also known under names like Hex or Polygon, is a two-player abstract strategy game with a simple objective but profound strategic depth.

The Core Rules

The game is played on a parallelogram-shaped board composed of hexagonal tiles. Here are the foundational rules that dictate gameplay:

  • Two Players: The game involves two players, typically represented by 'X' and 'O'.
  • Player 'X' Goal: Player 'X' wins by creating an unbroken chain of their 'X' stones connecting the top edge of the board to the bottom edge.
  • Player 'O' Goal: Player 'O' wins by creating an unbroken chain of their 'O' stones connecting the left edge of the board to the right edge.
  • Turns: Players take turns placing one of their stones on any unoccupied hexagonal tile.
  • Unbroken Chain: A chain is considered "unbroken" if every stone in the path is adjacent to the next one.
  • No Draws: A fascinating property of Hex is that it can never end in a draw. One player will always be able to form a winning path.

Representing the Board in Code

The input for our problem, as defined in the kodikra.com learning path, is typically an array of strings. Each string represents a row on the board, with characters like 'X', 'O', and '.' (for empty tiles). For example, a simple board might look like this:


. O . X .
 . X X O .
  O . O X .
   . X O . O
    X . X . O

While this is a human-readable format, it's not the most efficient for algorithmic processing. Our first task will be to parse this into a more structured format, like a 2D array (an array of arrays of characters), which allows for easy access to any tile using coordinates like board[row][col].


Why Use Graph Traversal for This Problem?

At its heart, the Connect game isn't about placing stones; it's about finding a path. This is a classic connectivity problem, and the most natural way to model and solve such problems in computer science is by thinking in terms of graphs.

A graph is a data structure consisting of a set of nodes (or vertices) and a set of edges that connect pairs of nodes. In our game:

  • Each hexagonal tile on the board is a node.
  • An edge exists between two nodes if their corresponding tiles are adjacent on the board.

The question "Has Player 'X' won?" can be rephrased in graph terminology: "Is there a path of 'X' nodes from any node on the top edge to any node on the bottom edge?" This reframing immediately points us toward graph traversal algorithms.

Depth-First Search (DFS) vs. Breadth-First Search (BFS)

Two primary algorithms are used to explore a graph: Depth-First Search (DFS) and Breadth-First Search (BFS).

  • Depth-First Search (DFS): This algorithm explores as far as possible along each branch before backtracking. It's like navigating a maze by taking one path, hitting a dead end, and then backtracking to the last junction to try a different path. It's often implemented using recursion, which makes the code very elegant for this type of problem.
  • Breadth-First Search (BFS): This algorithm explores all the neighbor nodes at the present depth prior to moving on to the nodes at the next depth level. It's like dropping a stone in a pond and watching the ripples expand outward. BFS is excellent for finding the shortest path, but for our game, we only care if any path exists, not necessarily the shortest one.

For the Connect game, DFS is a perfect fit. Its recursive nature maps cleanly to the problem of "Can I reach the other side from here?". It's generally simpler to implement than BFS for path existence checks and can be more memory-efficient in cases of a very wide but shallow search space.


How to Implement the Connect Game Solver in Crystal

Now we get to the core of the solution. We'll build our solver step-by-step, starting with board representation and culminating in the complete pathfinding logic. The elegance of Crystal's syntax and its strong type system will make this process clean and robust.

Step 1: The Game Class and Board Representation

First, let's set up a class to encapsulate our game logic. This class will hold the board state. We'll parse the input array of strings into a 2D array of characters (Array(Array(Char))) for easier manipulation.


# Represents the Connect game board and logic
class Game
  @board : Array(Array(Char))
  @width : Int32
  @height : Int32

  def initialize(board_str : Array(String))
    # Clean up the input strings by removing whitespace
    cleaned_board = board_str.map(&.gsub(/\s+/, ""))
    
    # Convert to a 2D array of characters
    @board = cleaned_board.map(&.chars)
    
    # Store dimensions for easy access
    @height = @board.size
    @width = @board[0]?.size || 0
  end

  # The main method to determine the winner will go here
  def winner
    # ... logic to be added
  end
end

This constructor takes the raw input, cleans it, and stores it in an instance variable @board. We also pre-calculate the width and height for convenience.

Step 2: Navigating the Hexagonal Grid

This is the trickiest part of a Hex-like game. A hexagonal grid is not a standard square grid. A tile at (row, col) has six neighbors, and their relative coordinates depend on the grid's orientation. For the typical "pointy-top" axial coordinate system used in these problems, the six neighbors of a cell (r, c) are:

  • (r, c - 1) - Left
  • (r, c + 1) - Right
  • (r - 1, c) - Top-Left
  • (r - 1, c + 1) - Top-Right
  • (r + 1, c) - Bottom-Right
  • (r + 1, c - 1) - Bottom-Left

Let's visualize this relationship.

      ● Cell (r-1, c)
       ╲
        ╲
 ● Cell (r-1, c+1) ─── ● Cell (r, c+1)
        ╱                │
       ╱                 │
    ┌────────┐      ┌────────┐
    │ Neighbor │      │   You    │
    └────────┘      └────────┘
                         │
                         │
        ╲                ● Cell (r+1, c)
         ╲              ╱
          ● Cell (r, c-1) ─── ● Cell (r+1, c-1)

We can encode these six directions in a helper method that returns a list of valid neighbor coordinates for any given tile.


private def neighbors(r : Int32, c : Int32)
  potential_neighbors = [
    {r, c - 1},      # Left
    {r, c + 1},      # Right
    {r - 1, c},      # Top-Left
    {r - 1, c + 1},  # Top-Right
    {r + 1, c},      # Bottom-Right
    {r + 1, c - 1},  # Bottom-Left
  ]

  # Filter out neighbors that are off the board
  potential_neighbors.select do |nr, nc|
    nr >= 0 && nr < @height && nc >= 0 && nc < @width
  end
end

This private method, neighbors, takes a row and column, calculates the six potential neighbor positions, and then filters the list to include only those that are actually within the board's boundaries.

Step 3: The Depth-First Search (DFS) Core Logic

Now we'll implement the recursive DFS function. This function will be the workhorse of our solution. Its goal is to answer the question: "Starting from tile (r, c), can we find a path of a specific color to a target edge?"

To avoid getting stuck in infinite loops (e.g., going from A to B and then back to A), we need to keep track of the tiles we've already visited during a specific search. A Set is the perfect data structure for this, offering fast lookups.

Here is the logic flow for our DFS algorithm:

    ● Start DFS(row, col, player, visited_set)
    │
    ▼
  ┌─────────────────────────────────┐
  │ Check Base Cases & Edge Cases   │
  └───────────────┬─────────────────┘
                  │
                  ├─ 1. Out of bounds? ───▶ Return false
                  │
                  ├─ 2. Wrong player?  ───▶ Return false
                  │
                  └─ 3. Already visited? ▶ Return false
                         │
                         ▼
             ┌─────────────────────┐
             │ Mark (row, col) as  │
             │      visited        │
             └──────────┬──────────┘
                        │
                        ▼
            ┌──────────────────────────┐
            │ Check for Winning Condition│
            │ (Have we reached the goal │
            │          edge?)          │
            └───────────┬──────────────┘
                        │
                  Yes   ▼    No
                  ┌─────◆─────┐
                  │ Win Found?│
                  └─────┬─────┘
                 ╱      │
                ╱       │
               ▼        ▼
  ┌────────────┐   ┌────────────────────────────────┐
  │ Return true│   │ For each neighbor of (row, col): │
  └────────────┘   │   Recursively call DFS(...)    │
                   │   If any call returns true,    │
                   │   then return true immediately │
                   └─────────────────┬──────────────┘
                                     │
                                     ▼
                          ┌─────────────────────────┐
                          │ No winning path found   │
                          │ from any neighbor.      │
                          │ Return false.           │
                          └─────────────────────────┘

Let's translate this logic into Crystal code. We'll create a private helper method, has_path?.


private def has_path?(r, c, player, visited)
  # Base Case 1: Out of bounds
  return false if r < 0 || r >= @height || c < 0 || c >= @width

  # Base Case 2: Not the player's tile
  return false if @board[r][c] != player

  # Base Case 3: Already visited in this search
  return false if visited.includes?({r, c})

  # Mark this tile as visited
  visited.add({r, c})

  # Winning Condition Check
  # For player 'X', the goal is the bottom row
  if player == 'X' && r == @height - 1
    return true
  end
  # For player 'O', the goal is the rightmost column
  if player == 'O' && c == @width - 1
    return true
  end

  # Recursive Step: Explore all neighbors
  neighbors(r, c).each do |nr, nc|
    if has_path?(nr, nc, player, visited)
      return true # Path found!
    end
  end

  # No path found from this tile
  false
end

Step 4: Putting It All Together in the `winner` Method

The final step is to create the public winner method. This method will orchestrate the search. It needs to check for a winner for both 'X' and 'O'.

  • To check for Player 'X' (Top to Bottom): We must start a DFS from every 'X' tile in the first row. If any of these searches successfully reach the last row, 'X' is the winner.
  • To check for Player 'O' (Left to Right): We must start a DFS from every 'O' tile in the first column. If any of these searches successfully reach the last column, 'O' is the winner.

def winner
  # Check for Player 'X' win (connects top to bottom)
  (0...@width).each do |c|
    visited = Set(Tuple(Int32, Int32)).new
    if has_path?(0, c, 'X', visited)
      return "X"
    end
  end

  # Check for Player 'O' win (connects left to right)
  (0...@height).each do |r|
    visited = Set(Tuple(Int32, Int32)).new
    if has_path?(r, 0, 'O', visited)
      return "O"
    end
  end

  # No winner found
  ""
end

Notice that we create a new visited set for each starting position. This is crucial. A failed path from one starting tile should not prevent us from exploring a potentially successful path from another starting tile.


The Complete Solution and Code Walkthrough

Here is the complete, final code, combining all the pieces we've built. It's a clean and efficient implementation that solves the Connect game challenge.

Full Crystal Code


# From the exclusive kodikra.com Crystal learning path
# Module 6: Connect Game Solver

require "set"

# Represents the Connect game board and logic to find a winner.
class Game
  @board : Array(Array(Char))
  @width : Int32
  @height : Int32

  def initialize(board_str : Array(String))
    # The input board has staggered rows. We clean them up.
    cleaned_board = board_str.map(&.gsub(/\s+/, ""))
    
    # Convert the cleaned strings into a 2D array of characters for easy access.
    @board = cleaned_board.map(&.chars)
    
    # Store board dimensions to avoid recalculating.
    @height = @board.size
    # Handle empty board case gracefully.
    @width = @board[0]?.try(&.size) || 0
  end

  # Determines the winner of the game ('X', 'O', or "" for no winner).
  def winner
    # Check for Player 'X' win (connects top row to bottom row)
    (0...@width).each do |start_col|
      # A new `visited` set is created for each potential starting path.
      visited = Set(Tuple(Int32, Int32)).new
      if has_path?(0, start_col, 'X', visited)
        return "X"
      end
    end

    # Check for Player 'O' win (connects left col to right col)
    (0...@height).each do |start_row|
      visited = Set(Tuple(Int32, Int32)).new
      if has_path?(start_row, 0, 'O', visited)
        return "O"
      end
    end

    # If no winning paths are found for either player, return an empty string.
    ""
  end

  private

  # The core Depth-First Search (DFS) algorithm.
  # Recursively checks for a path from (r, c) to the target edge for a given player.
  def has_path?(r, c, player, visited)
    # Base Case 1: The coordinates are outside the board boundaries.
    return false if r < 0 || r >= @height || c < 0 || c >= @width

    # Base Case 2: The tile at (r, c) does not belong to the current player.
    return false if @board[r][c] != player

    # Base Case 3: We have already visited this tile during the current search.
    # This is crucial to prevent infinite loops.
    return false if visited.includes?({r, c})

    # Mark the current tile as visited for this path search.
    visited.add({r, c})

    # Winning Condition Check:
    # If we are checking for 'X' and have reached the last row, we have found a winning path.
    return true if player == 'X' && r == @height - 1
    # If we are checking for 'O' and have reached the last column, we have found a winning path.
    return true if player == 'O' && c == @width - 1

    # Recursive Step: Explore all valid neighbors.
    neighbors(r, c).each do |nr, nc|
      # If a path is found from any neighbor, propagate the `true` result up the call stack.
      if has_path?(nr, nc, player, visited)
        return true
      end
    end

    # If we have explored all neighbors from this tile and none led to a win,
    # this path is a dead end.
    false
  end

  # Returns a list of valid neighbor coordinates for a given tile (r, c).
  private def neighbors(r : Int32, c : Int32)
    # These are the 6 relative coordinates for neighbors on a hex grid.
    potential_neighbors = [
      {r, c - 1},      # Left
      {r, c + 1},      # Right
      {r - 1, c},      # Top-Left
      {r - 1, c + 1},  # Top-Right
      {r + 1, c},      # Bottom-Right
      {r + 1, c - 1},  # Bottom-Left
    ]

    # Filter out any coordinates that fall outside the board's dimensions.
    potential_neighbors.select do |nr, nc|
      nr >= 0 && nr < @height && nc >= 0 && nc < @width
    end
  end
end

Running the Code

To test this solution, you would typically have a spec file within the kodikra learning path structure. You can run the tests from your terminal using Crystal's built-in testing tools.


# Navigate to your solution directory
cd path/to/crystal/connect

# Run the spec file associated with the module
crystal spec

This command will compile your code and the test suite, run the tests, and report whether your implementation passes all the test cases defined in the module.


Alternative Approaches and Performance

While DFS is an excellent and intuitive solution, it's not the only way to solve this problem. Understanding alternative algorithms provides deeper insight into computer science and helps you choose the right tool for the job in future projects.

Breadth-First Search (BFS)

As mentioned earlier, BFS could also be used. Instead of recursion, you would use a queue to manage the nodes to visit. You'd add all starting nodes to the queue and then, in a loop, dequeue a node, check its neighbors, and enqueue any valid, unvisited neighbors. BFS is guaranteed to find the shortest path, but since we only need to prove path existence, this benefit is not relevant here, and the implementation is often slightly more complex than a recursive DFS.

Disjoint Set Union (DSU) / Union-Find

For a highly optimized solution, especially for very large boards or repeated checks, the Union-Find algorithm is superior. This data structure is specifically designed to handle connectivity problems. You can treat each player's stones as elements in sets. When a player places a stone, you "union" its set with the sets of all its adjacent same-colored neighbors. A player wins if the "virtual" nodes representing their two opposite sides become part of the same set. While extremely fast, its implementation is less intuitive than graph traversal for beginners.

Pros and Cons of Different Approaches

Algorithm Pros Cons
Depth-First Search (DFS) - Intuitive recursive implementation.
- Memory efficient for deep, narrow paths.
- Can be slow on very wide, shallow graphs.
- May not find the shortest path (not an issue here).
Breadth-First Search (BFS) - Finds the shortest path.
- Good for wide, shallow graphs.
- Generally uses more memory (for the queue).
- Implementation is slightly more complex (requires a queue data structure).
Union-Find (DSU) - Extremely fast (near-constant time operations).
- Highly optimized for connectivity problems.
- Much less intuitive to understand and implement.
- Overkill for typical board sizes in this problem.

Frequently Asked Questions (FAQ)

Why is a hexagonal grid trickier than a square grid?

A square grid is simple: neighbors are at (r, c+1), (r, c-1), (r+1, c), and (r-1, c). A hexagonal grid has six neighbors, and their relative coordinates are not as straightforward, requiring careful handling of the coordinate system as shown in our neighbors method. This is a common source of bugs if not implemented carefully.

Is recursion necessary for DFS? Can I use a loop?

No, recursion is not strictly necessary. Any recursive algorithm can be converted into an iterative one using a stack data structure. You would push the starting node onto a stack. Then, in a loop, you pop a node, process it, and push its valid, unvisited neighbors onto the stack. For many, the recursive approach is more readable and closer to the logical definition of DFS.

What is the time complexity of this DFS solution?

The time complexity is proportional to the number of nodes (tiles) and edges (connections) on the graph. In the worst case, we might visit every tile on the board once. Therefore, the complexity is O(V + E), where V is the number of vertices (width * height) and E is the number of edges. For a grid, this simplifies to O(N), where N is the total number of tiles on the board.

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

Crystal's static type system provides compile-time checks that catch many common errors. For example, it ensures that our visited set contains only tuples of integers (Tuple(Int32, Int32)) and that our board is always an Array(Array(Char)). This prevents runtime errors from typos or incorrect data types, making the code more reliable and easier to refactor.

Why not just check every single possible path from top to bottom?

The number of possible paths on even a moderately sized board is astronomically large, a phenomenon known as combinatorial explosion. Trying to enumerate every path would be computationally infeasible. Graph traversal algorithms like DFS are efficient because they intelligently explore the graph, pruning branches that are dead ends or have already been visited, without ever needing to list all possible paths.

Can this algorithm handle different board sizes?

Absolutely. Our solution is generic because it reads the board's dimensions (@width and @height) in the constructor. It doesn't rely on hardcoded sizes, so it will work correctly for any valid parallelogram-shaped board provided as input.


Conclusion: Beyond the Game

We've successfully journeyed from understanding the simple rules of the Connect game to implementing a sophisticated, efficient solver in Crystal. By modeling the board as a graph and applying a Depth-First Search, we unlocked an elegant solution to a complex problem. This process highlights a core tenet of software engineering: abstracting a real-world problem into a known computational model.

The skills you've honed here—problem decomposition, algorithmic thinking, graph traversal, and implementing recursive logic—are not just for games. They are fundamental to solving problems in network routing, social network analysis, logistics, AI pathfinding, and countless other domains. Crystal, with its expressive syntax and powerful performance, proved to be an excellent tool for this task.

Disclaimer: The code and concepts in this article are based on the latest stable version of Crystal (1.12+). Syntax and standard library features may vary in other versions.

Ready to tackle the next challenge and continue your journey to mastery? Explore the full Crystal Learning Path on kodikra.com or dive deeper into the language with our complete Crystal programming guide.


Published by Kodikra — Your trusted Crystal learning resource.